PeachSyncService.kt

5.7 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 this device reachable on the relay so an already-paired peer can25 * reconnect. A browser extension can never listen, so if the phone is not26 * registered the two devices simply never sync and neither shows an error —27 * "no connection" and "nothing to sync" look identical.28 *29 * A browser extension can never listen for a connection, so the phone has to be30 * the side that is dialable — and a phone that is only dialable while its app31 * is foregrounded is not dialable in any useful sense. Doze and App Standby32 * will take an ordinary background socket away. This is the same standard33 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground34 * service with a visible, honest notification.35 *36 * The notification is deliberately plain about what it is. A persistent37 * notification the user does not understand gets the app force-stopped, which38 * is the one outcome that breaks sync entirely.39 */40@AndroidEntryPoint41class PeachSyncService : Service() {4243    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener44    @Inject lateinit var vault: VaultRepository45    @Inject lateinit var deviceStore: PairedDeviceStore46    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore47    @Inject lateinit var syncAlertStore: SyncAlertStore4849    private val scope = CoroutineScope(SupervisorJob())5051    override fun onBind(intent: Intent?): IBinder? = null5253    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {54        startForeground(NOTIFICATION_ID, buildNotification())55        startReconnectListener()56        // A start command means something changed — a new pairing, a manual57        // "sync now", the app coming forward. Re-read the device list now58        // rather than on the next interval; the listener is already running59        // on every start but the first.60        cloudRelayListener.requestReconcile()61        // START_STICKY: if the system reclaims us under pressure, come back —62        // a listener that silently stays down looks identical to a peer that63        // has simply gone offline, and the user gets no signal either way.64        return START_STICKY65    }6667    override fun onDestroy() {68        cloudRelayListener.stop()69        scope.cancel()70        super.onDestroy()71    }7273    /**74     * Only possible while unlocked — the sync identity is derived from the75     * vault's root key, so a locked phone genuinely has nothing to listen with.76     * That is also why this is re-attempted on every start command rather than77     * once: the service may well come up before the user has unlocked.78     */79    private fun startReconnectListener() {80        val master = vault.syncMasterKey() ?: return81        try {82            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))83            val identity = buildLocalIdentity(this, master)84            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {85                if (!vault.isUnlocked.value) null else VaultSyncAdapter(86                    repo = vault,87                    ourDeviceId = getOrCreateDeviceId(this),88                    pairedDevices = deviceStore,89                    pendingVouched = pendingVouchedStore,90                    syncAlerts = syncAlertStore,91                )92            }93        } finally {94            VaultCrypto.zero(master)95        }96    }9798    private fun buildNotification(): Notification {99        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager100        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {101            manager.createNotificationChannel(102                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {103                    description = "Shown while Peach is reachable for encrypted device sync."104                    setShowBadge(false)105                },106            )107        }108        val open = PendingIntent.getActivity(109            this,110            0,111            Intent(this, MainActivity::class.java),112            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,113        )114        return NotificationCompat.Builder(this, CHANNEL_ID)115            .setContentTitle("Peach sync is active")116            .setContentText("Your other devices can reach this phone for encrypted sync.")117            .setSmallIcon(R.drawable.ic_launcher_monochrome)118            .setOngoing(true)119            .setPriority(NotificationCompat.PRIORITY_LOW)120            .setContentIntent(open)121            .build()122    }123124    companion object {125        private const val CHANNEL_ID = "peach_sync"126        private const val NOTIFICATION_ID = 4201127128        fun start(context: Context) {129            val intent = Intent(context, PeachSyncService::class.java)130            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {131                context.startForegroundService(intent)132            } else {133                context.startService(intent)134            }135        }136137        fun stop(context: Context) {138            context.stopService(Intent(context, PeachSyncService::class.java))139        }140    }141}