package com.peachpasswords.sync import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.peachpasswords.MainActivity import com.peachpasswords.R import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.inject.Inject /** * Keeps this device reachable on its deterministic relay waiter edges and * drives its dialer edges. Every supported client now uses the same public-key * role ordering, but Android still needs a foreground service: Doze and App * Standby will take an ordinary background socket away. * * The notification is deliberately plain about what it is. A persistent * notification the user does not understand gets the app force-stopped. */ @AndroidEntryPoint class PeachSyncService : Service() { @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener @Inject lateinit var vault: VaultRepository @Inject lateinit var deviceStore: PairedDeviceStore @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore @Inject lateinit var syncAlertStore: SyncAlertStore @Inject lateinit var syncOutcomes: SyncOutcomeStore private val scope = CoroutineScope(SupervisorJob()) override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // The same restriction that can refuse [start] applies again here, and // a START_STICKY restart is exactly when it bites: the system brings us // back while the app is in the background. A service that stays up // without its notification is killed a second later by a different // exception, so stop deliberately and leave the reported reason // standing rather than come back to be refused every time. if (!startOrReportBlocked(syncOutcomes::report) { startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this)) } ) { stopSelf() return START_NOT_STICKY } val listenerStarted = startReconnectListener() // A start command means something changed — a new pairing, a manual // "sync now", the app coming forward. Re-read the device list now // rather than on the next interval; the listener is already running // on every start but the first. // Replace existing waiters as well as re-reading the device list. A // locally active coroutine does not prove its idle WebSocket still // routes through the relay, and "sync now" must recover that state. cloudRelayListener.requestReconcile( refreshExisting = shouldRefreshExistingWaiters(listenerStarted), ) // START_STICKY: if the system reclaims us under pressure, come back — // a listener that silently stays down looks identical to a peer that // has simply gone offline, and the user gets no signal either way. return START_STICKY } override fun onDestroy() { cloudRelayListener.stop() scope.cancel() super.onDestroy() } /** * Only possible while unlocked — the sync identity is derived from the * vault's root key, so a locked phone genuinely has nothing to listen with. * That is also why this is re-attempted on every start command rather than * once: the service may well come up before the user has unlocked. * * The early return is deliberately the one terminating path that reports no * outcome. Coming up locked is the normal boot order, not a failed sync; * reporting it would leave a red light waiting on the other side of the * unlock screen. The locked case a person can actually see is reported from * `SyncViewModel.syncNow`, and from a session that reaches a locked vault * mid-handshake. */ private fun startReconnectListener(): Boolean { val master = vault.syncMasterKey() ?: return false return try { val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this)) val identity = buildLocalIdentity(this, master) cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) { if (!vault.isUnlocked.value) null else VaultSyncAdapter( repo = vault, ourDeviceId = getOrCreateDeviceId(this), pairedDevices = deviceStore, pendingVouched = pendingVouchedStore, syncAlerts = syncAlertStore, ) } } finally { VaultCrypto.zero(master) } } companion object { fun start(context: Context) { val intent = Intent(context, PeachSyncService::class.java) startOrReportBlocked({ context.reportSyncOutcome(it) }) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { context.startService(intent) } } } fun stop(context: Context) { context.stopService(Intent(context, PeachSyncService::class.java)) } } } internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync" internal const val SYNC_NOTIFICATION_ID = 4201 internal const val ACTION_RESTORE_SYNC_NOTIFICATION = "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION" internal fun buildSyncNotification(context: Context): Notification { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel( NotificationChannel( SYNC_NOTIFICATION_CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Shown while Peach is reachable for encrypted device sync." setShowBadge(false) }, ) } val open = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) val restore = PendingIntent.getBroadcast( context, 0, Intent(context, SyncNotificationDismissedReceiver::class.java) .setAction(ACTION_RESTORE_SYNC_NOTIFICATION), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID) .setContentTitle("Peach sync is active") .setContentText("Your other devices can reach this phone for encrypted sync.") .setSmallIcon(R.drawable.ic_launcher_monochrome) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setContentIntent(open) .setDeleteIntent(restore) .build() .also { // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows // users to swipe ongoing notifications individually, so the // deleteIntent above restores this status surface while the user // preference still says background sync is active. it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR } } internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean = action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled /** * Android 14 permits an ongoing foreground-service notification to be swiped * away while its service keeps running. Peach's notification is the only * visible indication that the phone remains reachable, so restore it after a * user dismissal whenever background sync is still enabled. */ class SyncNotificationDismissedReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (!shouldRestoreSyncNotification( intent?.action, BackgroundSyncPreference.isEnabled(context), ) ) { return } val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context)) } } /** * Every sync path asks for this service first, so a throw here is a sync that * never happened — and it used to take the caller with it: a crash from the * pairing and unlock screens, nothing at all from the fire-and-forget * coroutines. Returns false when the start was refused, having said why. * * [IllegalStateException] rather than API 31's * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a * subclass of this one and does not exist on this app's minSdk, and 26..30 * throws the plain one for the same background start. Catching the supertype * covers both without an SDK_INT guard around a class that may not be there. */ internal fun startOrReportBlocked( report: (SyncOutcomeSummary) -> Unit, start: () -> Unit, ): Boolean = try { start() true } catch (blocked: IllegalStateException) { report( SyncOutcomeSummary( reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED, detail = blocked.message, ), ) false } catch (error: Exception) { // Nothing about asking for a service is worth crashing the app over; the // reason is the only part of this a person can act on. report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message)) false } /** * [PeachSyncService.start] is called from five screens and coordinators that * hold no injector of their own, so the store is fetched here rather than * threaded through every one of them. Wrapped because a store that cannot even * be opened must not become the crash the guard above exists to prevent. */ private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) { runCatching { EntryPointAccessors .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java) .syncOutcomes() .report(summary) } } @EntryPoint @InstallIn(SingletonComponent::class) internal interface SyncOutcomeEntryPoint { fun syncOutcomes(): SyncOutcomeStore }