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.android.AndroidEntryPoint 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 the pairing/sync listener reachable. * * 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 listener: SyncReconnectListener @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 private val scope = CoroutineScope(SupervisorJob()) override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(NOTIFICATION_ID, buildNotification()) startReconnectListener() // 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() { listener.stop() 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. */ private fun startReconnectListener() { val master = vault.syncMasterKey() ?: return try { val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this)) val identity = buildLocalIdentity(this, master) listener.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, ) } 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) 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)) } } }