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.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 the relay so an already-paired peer can * reconnect. A browser extension can never listen, so if the phone is not * registered the two devices simply never sync and neither shows an error — * "no connection" and "nothing to sync" look identical. * * A browser extension can never listen for a connection, so the phone has to be * the side that is dialable — and a phone that is only dialable while its app * is foregrounded is not dialable in any useful sense. Doze and App Standby * will take an ordinary background socket away. This is the same standard * pattern Syncthing and Tailscale use for exactly the same reason: a foreground * service with a visible, honest notification. * * The notification is deliberately plain about what it is. A persistent * notification the user does not understand gets the app force-stopped, which * is the one outcome that breaks sync entirely. */ @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(NOTIFICATION_ID, buildNotification()) } ) { stopSelf() return START_NOT_STICKY } 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. cloudRelayListener.requestReconcile() // 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() { val master = vault.syncMasterKey() ?: 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) } } private fun buildNotification(): Notification { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel( NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply { description = "Shown while Peach is reachable for encrypted device sync." setShowBadge(false) }, ) } val open = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) return NotificationCompat.Builder(this, 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) .build() } companion object { private const val CHANNEL_ID = "peach_sync" private const val NOTIFICATION_ID = 4201 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)) } } } /** * 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 }