PeachSyncService.kt

9.0 KB · raw

1package com.peachpasswords.sync23import android.app.Notification4import android.app.NotificationChannel5import android.app.NotificationManager6import android.app.PendingIntent7import android.app.Service8import android.content.Context9import android.content.Intent10import android.os.Build11import android.os.IBinder12import androidx.core.app.NotificationCompat13import com.peachpasswords.MainActivity14import com.peachpasswords.R15import dagger.hilt.EntryPoint16import dagger.hilt.InstallIn17import dagger.hilt.android.AndroidEntryPoint18import dagger.hilt.android.EntryPointAccessors19import dagger.hilt.components.SingletonComponent20import com.peachpasswords.vault.VaultCrypto21import com.peachpasswords.vault.VaultRepository22import kotlinx.coroutines.CoroutineScope23import kotlinx.coroutines.SupervisorJob24import kotlinx.coroutines.cancel25import javax.inject.Inject2627/**28 * Keeps this device reachable on the relay so an already-paired peer can29 * reconnect. A browser extension can never listen, so if the phone is not30 * registered the two devices simply never sync and neither shows an error —31 * "no connection" and "nothing to sync" look identical.32 *33 * A browser extension can never listen for a connection, so the phone has to be34 * the side that is dialable — and a phone that is only dialable while its app35 * is foregrounded is not dialable in any useful sense. Doze and App Standby36 * will take an ordinary background socket away. This is the same standard37 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground38 * service with a visible, honest notification.39 *40 * The notification is deliberately plain about what it is. A persistent41 * notification the user does not understand gets the app force-stopped, which42 * is the one outcome that breaks sync entirely.43 */44@AndroidEntryPoint45class PeachSyncService : Service() {4647    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener48    @Inject lateinit var vault: VaultRepository49    @Inject lateinit var deviceStore: PairedDeviceStore50    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore51    @Inject lateinit var syncAlertStore: SyncAlertStore52    @Inject lateinit var syncOutcomes: SyncOutcomeStore5354    private val scope = CoroutineScope(SupervisorJob())5556    override fun onBind(intent: Intent?): IBinder? = null5758    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {59        // The same restriction that can refuse [start] applies again here, and60        // a START_STICKY restart is exactly when it bites: the system brings us61        // back while the app is in the background. A service that stays up62        // without its notification is killed a second later by a different63        // exception, so stop deliberately and leave the reported reason64        // standing rather than come back to be refused every time.65        if (!startOrReportBlocked(syncOutcomes::report) {66                startForeground(NOTIFICATION_ID, buildNotification())67            }68        ) {69            stopSelf()70            return START_NOT_STICKY71        }72        startReconnectListener()73        // A start command means something changed — a new pairing, a manual74        // "sync now", the app coming forward. Re-read the device list now75        // rather than on the next interval; the listener is already running76        // on every start but the first.77        cloudRelayListener.requestReconcile()78        // START_STICKY: if the system reclaims us under pressure, come back —79        // a listener that silently stays down looks identical to a peer that80        // has simply gone offline, and the user gets no signal either way.81        return START_STICKY82    }8384    override fun onDestroy() {85        cloudRelayListener.stop()86        scope.cancel()87        super.onDestroy()88    }8990    /**91     * Only possible while unlocked — the sync identity is derived from the92     * vault's root key, so a locked phone genuinely has nothing to listen with.93     * That is also why this is re-attempted on every start command rather than94     * once: the service may well come up before the user has unlocked.95     *96     * The early return is deliberately the one terminating path that reports no97     * outcome. Coming up locked is the normal boot order, not a failed sync;98     * reporting it would leave a red light waiting on the other side of the99     * unlock screen. The locked case a person can actually see is reported from100     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault101     * mid-handshake.102     */103    private fun startReconnectListener() {104        val master = vault.syncMasterKey() ?: return105        try {106            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))107            val identity = buildLocalIdentity(this, master)108            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {109                if (!vault.isUnlocked.value) null else VaultSyncAdapter(110                    repo = vault,111                    ourDeviceId = getOrCreateDeviceId(this),112                    pairedDevices = deviceStore,113                    pendingVouched = pendingVouchedStore,114                    syncAlerts = syncAlertStore,115                )116            }117        } finally {118            VaultCrypto.zero(master)119        }120    }121122    private fun buildNotification(): Notification {123        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager124        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {125            manager.createNotificationChannel(126                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {127                    description = "Shown while Peach is reachable for encrypted device sync."128                    setShowBadge(false)129                },130            )131        }132        val open = PendingIntent.getActivity(133            this,134            0,135            Intent(this, MainActivity::class.java),136            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,137        )138        return NotificationCompat.Builder(this, CHANNEL_ID)139            .setContentTitle("Peach sync is active")140            .setContentText("Your other devices can reach this phone for encrypted sync.")141            .setSmallIcon(R.drawable.ic_launcher_monochrome)142            .setOngoing(true)143            .setPriority(NotificationCompat.PRIORITY_LOW)144            .setContentIntent(open)145            .build()146    }147148    companion object {149        private const val CHANNEL_ID = "peach_sync"150        private const val NOTIFICATION_ID = 4201151152        fun start(context: Context) {153            val intent = Intent(context, PeachSyncService::class.java)154            startOrReportBlocked({ context.reportSyncOutcome(it) }) {155                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {156                    context.startForegroundService(intent)157                } else {158                    context.startService(intent)159                }160            }161        }162163        fun stop(context: Context) {164            context.stopService(Intent(context, PeachSyncService::class.java))165        }166    }167}168169/**170 * Every sync path asks for this service first, so a throw here is a sync that171 * never happened — and it used to take the caller with it: a crash from the172 * pairing and unlock screens, nothing at all from the fire-and-forget173 * coroutines. Returns false when the start was refused, having said why.174 *175 * [IllegalStateException] rather than API 31's176 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a177 * subclass of this one and does not exist on this app's minSdk, and 26..30178 * throws the plain one for the same background start. Catching the supertype179 * covers both without an SDK_INT guard around a class that may not be there.180 */181internal fun startOrReportBlocked(182    report: (SyncOutcomeSummary) -> Unit,183    start: () -> Unit,184): Boolean = try {185    start()186    true187} catch (blocked: IllegalStateException) {188    report(189        SyncOutcomeSummary(190            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,191            detail = blocked.message,192        ),193    )194    false195} catch (error: Exception) {196    // Nothing about asking for a service is worth crashing the app over; the197    // reason is the only part of this a person can act on.198    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))199    false200}201202/**203 * [PeachSyncService.start] is called from five screens and coordinators that204 * hold no injector of their own, so the store is fetched here rather than205 * threaded through every one of them. Wrapped because a store that cannot even206 * be opened must not become the crash the guard above exists to prevent.207 */208private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {209    runCatching {210        EntryPointAccessors211            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)212            .syncOutcomes()213            .report(summary)214    }215}216217@EntryPoint218@InstallIn(SingletonComponent::class)219internal interface SyncOutcomeEntryPoint {220    fun syncOutcomes(): SyncOutcomeStore221}