Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class SettingsRepository

// Privacy preferences
val BLOCK_UNKNOWN_SENDERS = booleanPreferencesKey("block_unknown_senders")
val ALLOW_CALLS_FROM_CONTACTS_ONLY = booleanPreferencesKey("allow_calls_from_contacts_only")
val ALLOW_VOICE_CALLS = booleanPreferencesKey("allow_voice_calls")

// Telemetry collector preferences
val TELEMETRY_COLLECTOR_ADDRESS = stringPreferencesKey("telemetry_collector_address")
Expand Down Expand Up @@ -1577,6 +1579,8 @@ class SettingsRepository
// Cross-process SharedPreferences keys (for settings read by the service)
const val CROSS_PROCESS_PREFS_NAME = "cross_process_settings"
const val KEY_BLOCK_UNKNOWN_SENDERS = "block_unknown_senders"
const val KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY = "allow_calls_from_contacts_only"
const val KEY_ALLOW_VOICE_CALLS = "allow_voice_calls"

/** Default telemetry send interval: 5 minutes */
const val DEFAULT_TELEMETRY_SEND_INTERVAL_SECONDS = 300
Expand Down Expand Up @@ -1775,6 +1779,121 @@ class SettingsRepository
.apply()
}

/**
* Flow of the calls-from-contacts-only setting.
* When enabled, only contacts can establish incoming voice calls.
* Non-contact callers' link attempts are silently dropped after
* identification (no STATUS_RINGING, no UI surface).
* Defaults to false if not set (allow all callers - preserves existing behavior).
*/
val allowCallsFromContactsOnlyFlow: Flow<Boolean> =
context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] ?: false
}.distinctUntilChanged()

/**
* Get the calls-from-contacts-only setting (non-flow).
*/
suspend fun getAllowCallsFromContactsOnly(): Boolean =
context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] ?: false
}.first()

/**
* Save the calls-from-contacts-only setting.
*
* Also writes to SharedPreferences with MODE_MULTI_PROCESS so the service process
* can read it (DataStore doesn't support reliable cross-process reads).
*
* @param enabled Whether to gate incoming calls to contacts only
*/
@Suppress("DEPRECATION") // MODE_MULTI_PROCESS needed for cross-process reads
suspend fun saveAllowCallsFromContactsOnly(enabled: Boolean) {
// Write to DataStore for local flow/UI
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.ALLOW_CALLS_FROM_CONTACTS_ONLY] = enabled
}
// Write to SharedPreferences for cross-process access by the service
context
.getSharedPreferences(CROSS_PROCESS_PREFS_NAME, Context.MODE_MULTI_PROCESS)
.edit()
.putBoolean(KEY_ALLOW_CALLS_FROM_CONTACTS_ONLY, enabled)
.apply()
}

/**
* Flow of the master allow-voice-calls setting.
* When false, the inbound LXST telephony destination is deregistered and
* no announces are sent — peers see the device as unreachable for calls.
* Outbound calls remain functional regardless.
* Defaults to true if not set (allow incoming - preserves existing behavior).
*/
val allowVoiceCallsFlow: Flow<Boolean> =
context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.ALLOW_VOICE_CALLS] ?: true
}.distinctUntilChanged()

/**
* Get the allow-voice-calls setting (non-flow).
*/
suspend fun getAllowVoiceCalls(): Boolean =
context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.ALLOW_VOICE_CALLS] ?: true
}.first()

/**
* Save the master allow-voice-calls setting.
*
* Also writes to SharedPreferences with MODE_MULTI_PROCESS so the service process
* can read it (DataStore doesn't support reliable cross-process reads).
*
* @param enabled Whether to accept incoming voice calls
*/
@Suppress("DEPRECATION") // MODE_MULTI_PROCESS needed for cross-process reads
suspend fun saveAllowVoiceCalls(enabled: Boolean) {
// Write to DataStore for local flow/UI
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.ALLOW_VOICE_CALLS] = enabled
}
// Write to SharedPreferences so the :reticulum service can re-read
// the persisted value on next process start (the SharedPreferences
// change listener does NOT fire across processes — see Intent below).
context
.getSharedPreferences(CROSS_PROCESS_PREFS_NAME, Context.MODE_MULTI_PROCESS)
.edit()
.putBoolean(KEY_ALLOW_VOICE_CALLS, enabled)
.apply()
// Signal the :reticulum service to apply the new state at runtime.
// SharedPreferences listeners only fire in the writing process on
// Android, so the persisted value alone isn't enough — without
// this Intent the service keeps the destination registered until
// its next cold start. Mirrors the existing ACTION_RESTART_BLE
// notification pattern.
try {
val intent =
android.content.Intent(
context,
com.lxmf.messenger.service.ReticulumService::class.java,
).apply {
action = com.lxmf.messenger.service.ReticulumService.ACTION_SET_ALLOW_VOICE_CALLS
putExtra(
com.lxmf.messenger.service.ReticulumService.EXTRA_ALLOW_VOICE_CALLS,
enabled,
)
}
context.startService(intent)
} catch (e: Exception) {
android.util.Log.w(
"SettingsRepository",
"Failed to signal ReticulumService of Allow voice calls change: ${e.message}",
)
}
}

// Custom theme methods (delegated to CustomThemeRepository)

/**
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/com/lxmf/messenger/service/ReticulumService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class ReticulumService : Service() {
const val ACTION_START = "com.lxmf.messenger.service.START"
const val ACTION_STOP = "com.lxmf.messenger.service.STOP"
const val ACTION_RESTART_BLE = "com.lxmf.messenger.RESTART_BLE"
const val ACTION_SET_ALLOW_VOICE_CALLS = "com.lxmf.messenger.SET_ALLOW_VOICE_CALLS"
const val EXTRA_ALLOW_VOICE_CALLS = "allow_voice_calls"
}

// Coroutine scope for background tasks
Expand Down Expand Up @@ -212,6 +214,20 @@ class ReticulumService : Service() {
// Restart BLE interface after permissions granted
handleRestartBle(intent)
}
ACTION_SET_ALLOW_VOICE_CALLS -> {
// UI process flipped the master "Allow voice calls" toggle.
// SharedPreferences.OnSharedPreferenceChangeListener does NOT
// fire across processes, so the UI explicitly signals the
// service via this Intent. The default `true` keeps existing
// behaviour if the extra is missing (e.g., stale broadcast).
val allowed = intent.getBooleanExtra(EXTRA_ALLOW_VOICE_CALLS, true)
Log.i(TAG, "Received ACTION_SET_ALLOW_VOICE_CALLS → $allowed")
if (::binder.isInitialized) {
binder.setAllowVoiceCalls(allowed)
} else {
Log.w(TAG, "Service not yet initialized — Allow voice calls state will be applied at setupCallManager")
}
}
}

return START_STICKY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.lxmf.messenger.service.manager.PythonWrapperManager.Companion.getDict
import com.lxmf.messenger.service.manager.RoutingManager
import com.lxmf.messenger.service.manager.ServiceNotificationManager
import com.lxmf.messenger.service.persistence.ServicePersistenceManager
import com.lxmf.messenger.service.persistence.ServiceSettingsAccessor
import com.lxmf.messenger.service.state.ServiceState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -63,6 +64,7 @@ class ReticulumServiceBinder(
private val notificationManager: ServiceNotificationManager,
private val bleCoordinator: BleCoordinator,
private val persistenceManager: ServicePersistenceManager,
private val settingsAccessor: ServiceSettingsAccessor,
private val scope: CoroutineScope,
private val onInitialized: () -> Unit,
private val onShutdown: () -> Unit,
Expand All @@ -75,6 +77,7 @@ class ReticulumServiceBinder(
// RNode bridge - created lazily when needed
private var rnodeBridge: KotlinRNodeBridge? = null


// ===========================================
// Lifecycle Methods
// ===========================================
Expand Down Expand Up @@ -1477,12 +1480,54 @@ class ReticulumServiceBinder(
if (callManagerInitialized) {
registerCallCoordinatorListeners()
wrapperManager.setupTelephone()
// Register the contact-check predicate so Python can gate
// incoming links by the "Calls from contacts only" toggle.
wrapperManager.setupContactCheckCallback()
// Apply the master "Allow voice calls" toggle's current state.
// Runtime changes are signalled via ACTION_SET_ALLOW_VOICE_CALLS
// because SharedPreferences.OnSharedPreferenceChangeListener does
// not fire across processes.
applyInitialAllowVoiceCallsState()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to setup CallManager: ${e.message}", e)
}
}

/**
* Read the persisted "Allow voice calls" toggle and apply it to Python.
*
* Default is true (preserves existing behaviour for users who haven't
* touched the toggle). When false, Python.disable_lxst_incoming() is
* invoked immediately after setupCallManager so the IN destination is
* never visible to the network for this session.
*/
private fun applyInitialAllowVoiceCallsState() {
try {
val allowed = settingsAccessor.getAllowVoiceCalls()
Log.d(TAG, "Initial Allow voice calls state: $allowed")
if (!allowed) {
wrapperManager.setLxstIncomingEnabled(false)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read initial Allow voice calls state: ${e.message}", e)
}
}

/**
* Apply a runtime change to the master "Allow voice calls" toggle.
*
* Invoked from [ReticulumService.onStartCommand] when the UI process
* sends `ACTION_SET_ALLOW_VOICE_CALLS`. The Intent path is used instead
* of `SharedPreferences.OnSharedPreferenceChangeListener` because
* SharedPreferences change listeners only fire in the process that
* wrote the value — they do not propagate across processes on Android.
*/
fun setAllowVoiceCalls(allowed: Boolean) {
Log.i(TAG, "Allow voice calls runtime change → $allowed")
wrapperManager.setLxstIncomingEnabled(allowed)
}

/** Register listeners for IPC notification to UI process. */
private fun registerCallCoordinatorListeners() {
val callBridge =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ object ServiceModule {
val settingsAccessor = ServiceSettingsAccessor(context)
val persistenceManager = ServicePersistenceManager(context, scope, settingsAccessor)

// Phase 2: Python wrapper (depends on state, context, scope)
val wrapperManager = PythonWrapperManager(state, context, scope)
// Phase 2: Python wrapper (depends on state, context, scope, settingsAccessor)
val wrapperManager = PythonWrapperManager(state, context, scope, settingsAccessor)

// Phase 3: Health monitoring (depends on wrapperManager, scope)
// Started after initialization completes (see ReticulumServiceBinder)
Expand Down Expand Up @@ -170,6 +170,7 @@ object ServiceModule {
notificationManager = managers.notificationManager,
bleCoordinator = managers.bleCoordinator,
persistenceManager = managers.persistenceManager,
settingsAccessor = managers.settingsAccessor,
scope = scope,
onInitialized = onInitialized,
onShutdown = onShutdown,
Expand Down
Loading
Loading