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.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.security.MessageDigest 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 @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore @Inject lateinit var syncGeneration: SyncGenerationStore private val scope = CoroutineScope(SupervisorJob()) override fun onBind(intent: Intent?): IBinder? = null /** * The reported reason when this device must not sync, or null when it may. * §11.3: RESET_REQUIRED and RESET_CONFIRMED block every session. */ private fun syncRefusal(): SyncOutcomeSummary? = syncRefusalReason(syncGeneration, System.currentTimeMillis()) ?.let { SyncOutcomeSummary(reason = it) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Refuse before foreground promotion: reset-required is an expected // outcome, not a lifecycle exception, and must not mint an identity. syncRefusal()?.let { syncOutcomes.report(it) stopSelf() return START_NOT_STICKY } // A decoy unlock has no sync identity. Stop before foreground promotion // so it cannot expose a notification or drain the locked spool. if (vault.isUnlocked.value && vault.syncVaultId() == null) { stopSelf() return START_NOT_STICKY } if (!startOrReportBlocked(syncOutcomes::report) { startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this)) }) { stopSelf() return START_NOT_STICKY } if (vault.isUnlocked.value) { scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } } } val listenerStarted = startReconnectListener() cloudRelayListener.requestReconcile( refreshExisting = shouldRefreshExistingWaiters(listenerStarted), ) return START_STICKY } override fun onDestroy() { cloudRelayListener.stop() scope.cancel() super.onDestroy() } /** Uses the Keystore-wrapped transport identity after full lock. */ private fun startReconnectListener(): Boolean { val master = vault.syncMasterKey() var credentials: ReconnectListenerCredentials? = null return try { credentials = if (master != null) { lockedSyncSpool.refresh(vault) deriveUnlockedListenerCredentials( credentials = loadOrCreateLocalIdentity(this), deviceName = getDeviceName(this), relayUrl = RelaySettingsStore.readRelayUrl(this), ) } else { lockedSyncSpool.identity()?.let { ReconnectListenerCredentials(it.first, it.second) } ?: return false } cloudRelayListener.start( scope, credentials.keys.secretKey, credentials.keys.publicKey, credentials.identity, ) { // A decoy session can replace a real unlock while an existing // relay callback is still being scheduled. Cancel that callback // without reporting a misleading locked-vault outcome. when { vault.syncVaultId() == null -> throw CancellationException("decoy session") // §11: a device waiting on, or part-way through, its reset opens // no session at all — neither unlocked nor through the locked // spool, whose staged ciphertext belongs to the retired // generation and must not be applied. else -> syncRefusal()?.let { refusal -> syncOutcomes.report(refusal) null } ?: if (!vault.isUnlocked.value) { lockedSyncSpool.adapter() } else { val credentials = loadOrCreateLocalIdentity(this) VaultSyncAdapter( repo = vault, ourDeviceId = credentials.deviceId, pairedDevices = deviceStore, pendingVouched = pendingVouchedStore, syncAlerts = syncAlertStore, localCredentials = credentials, ) } } } } finally { master?.let(VaultCrypto::zero) credentials?.keys?.secretKey?.let(VaultCrypto::zero) } } 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 data class ReconnectListenerCredentials( val keys: SyncIdentityKeypair, val identity: LocalDeviceIdentity, ) internal fun deriveUnlockedListenerCredentials( credentials: LocalIdentityCredentials, deviceName: String, relayUrl: String = Rendezvous.DEFAULT_RELAY_URL, ): ReconnectListenerCredentials { val keys = localSyncKeypair(credentials) val identity = LocalDeviceIdentity( deviceId = credentials.deviceId, deviceName = deviceName, deviceType = DeviceType.ANDROID, longTermPublicKeyHex = pubkeyToHex(keys.publicKey), signingPublicKeyHex = pubkeyToHex(credentials.ed25519PublicKey), relayUrl = relayUrl, ) return ReconnectListenerCredentials(keys, identity) } 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 on") .setContentText("Checking for encrypted changes from your paired devices.") .setSmallIcon(R.drawable.ic_launcher_monochrome) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .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 }