PeachSyncService.kt

5.1 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.android.AndroidEntryPoint16import com.peachpasswords.vault.VaultCrypto17import com.peachpasswords.vault.VaultRepository18import kotlinx.coroutines.CoroutineScope19import kotlinx.coroutines.SupervisorJob20import kotlinx.coroutines.cancel21import javax.inject.Inject2223/**24 * Keeps the pairing/sync listener reachable.25 *26 * A browser extension can never listen for a connection, so the phone has to be27 * the side that is dialable — and a phone that is only dialable while its app28 * is foregrounded is not dialable in any useful sense. Doze and App Standby29 * will take an ordinary background socket away. This is the same standard30 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground31 * service with a visible, honest notification.32 *33 * The notification is deliberately plain about what it is. A persistent34 * notification the user does not understand gets the app force-stopped, which35 * is the one outcome that breaks sync entirely.36 */37@AndroidEntryPoint38class PeachSyncService : Service() {3940    @Inject lateinit var listener: SyncReconnectListener41    @Inject lateinit var vault: VaultRepository42    @Inject lateinit var deviceStore: PairedDeviceStore43    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore44    @Inject lateinit var syncAlertStore: SyncAlertStore4546    private val scope = CoroutineScope(SupervisorJob())4748    override fun onBind(intent: Intent?): IBinder? = null4950    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {51        startForeground(NOTIFICATION_ID, buildNotification())52        startReconnectListener()53        // START_STICKY: if the system reclaims us under pressure, come back —54        // a listener that silently stays down looks identical to a peer that55        // has simply gone offline, and the user gets no signal either way.56        return START_STICKY57    }5859    override fun onDestroy() {60        listener.stop()61        scope.cancel()62        super.onDestroy()63    }6465    /**66     * Only possible while unlocked — the sync identity is derived from the67     * vault's root key, so a locked phone genuinely has nothing to listen with.68     * That is also why this is re-attempted on every start command rather than69     * once: the service may well come up before the user has unlocked.70     */71    private fun startReconnectListener() {72        val master = vault.syncMasterKey() ?: return73        try {74            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))75            val identity = buildLocalIdentity(this, master)76            listener.start(scope, keys.secretKey, keys.publicKey, identity) {77                if (!vault.isUnlocked.value) null else VaultSyncAdapter(78                    repo = vault,79                    ourDeviceId = getOrCreateDeviceId(this),80                    pairedDevices = deviceStore,81                    pendingVouched = pendingVouchedStore,82                    syncAlerts = syncAlertStore,83                )84            }85        } finally {86            VaultCrypto.zero(master)87        }88    }8990    private fun buildNotification(): Notification {91        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager92        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {93            manager.createNotificationChannel(94                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {95                    description = "Shown while Peach is reachable for syncing on your network."96                    setShowBadge(false)97                },98            )99        }100        val open = PendingIntent.getActivity(101            this,102            0,103            Intent(this, MainActivity::class.java),104            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,105        )106        return NotificationCompat.Builder(this, CHANNEL_ID)107            .setContentTitle("Peach sync is active")108            .setContentText("Your other devices can reach this phone on your network.")109            .setSmallIcon(R.drawable.ic_launcher_monochrome)110            .setOngoing(true)111            .setPriority(NotificationCompat.PRIORITY_LOW)112            .setContentIntent(open)113            .build()114    }115116    companion object {117        private const val CHANNEL_ID = "peach_sync"118        private const val NOTIFICATION_ID = 4201119120        fun start(context: Context) {121            val intent = Intent(context, PeachSyncService::class.java)122            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {123                context.startForegroundService(intent)124            } else {125                context.startService(intent)126            }127        }128129        fun stop(context: Context) {130            context.stopService(Intent(context, PeachSyncService::class.java))131        }132    }133}