1package com.peachpasswords.sync23import android.app.Notification4import android.app.NotificationChannel5import android.app.NotificationManager6import android.app.PendingIntent7import android.app.Service8import android.content.BroadcastReceiver9import android.content.Context10import android.content.Intent11import android.os.Build12import android.os.IBinder13import androidx.core.app.NotificationCompat14import com.peachpasswords.MainActivity15import com.peachpasswords.R16import dagger.hilt.EntryPoint17import dagger.hilt.InstallIn18import dagger.hilt.android.AndroidEntryPoint19import dagger.hilt.android.EntryPointAccessors20import dagger.hilt.components.SingletonComponent21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import kotlinx.coroutines.CoroutineScope24import kotlinx.coroutines.SupervisorJob25import kotlinx.coroutines.cancel26import kotlinx.coroutines.launch27import java.security.MessageDigest28import javax.inject.Inject2930313233343536373839@AndroidEntryPoint40class PeachSyncService : Service() {4142 @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener43 @Inject lateinit var vault: VaultRepository44 @Inject lateinit var deviceStore: PairedDeviceStore45 @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore46 @Inject lateinit var syncAlertStore: SyncAlertStore47 @Inject lateinit var syncOutcomes: SyncOutcomeStore48 @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore4950 private val scope = CoroutineScope(SupervisorJob())5152 override fun onBind(intent: Intent?): IBinder? = null5354 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {55 56 57 58 59 60 61 if (!startOrReportBlocked(syncOutcomes::report) {62 startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))63 }64 ) {65 stopSelf()66 return START_NOT_STICKY67 }68 if (vault.isUnlocked.value) {69 scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }70 }71 val listenerStarted = startReconnectListener()72 73 74 75 76 77 78 79 cloudRelayListener.requestReconcile(80 refreshExisting = shouldRefreshExistingWaiters(listenerStarted),81 )82 83 84 85 return START_STICKY86 }8788 override fun onDestroy() {89 cloudRelayListener.stop()90 scope.cancel()91 super.onDestroy()92 }9394 9596979899100101102103104105 private fun startReconnectListener(): Boolean {106 val master = vault.syncMasterKey() ?: return false107 var credentials: ReconnectListenerCredentials? = null108 return try {109 lockedSyncSpool.refresh(vault)110 credentials = deriveUnlockedListenerCredentials(111 masterKey = master,112 deviceId = getOrCreateDeviceId(this),113 deviceName = getDeviceName(this),114 relayUrl = RelaySettingsStore.readRelayUrl(this),115 )116 cloudRelayListener.start(117 scope,118 credentials.keys.secretKey,119 credentials.keys.publicKey,120 credentials.identity,121 ) {122 if (!vault.isUnlocked.value) lockedSyncSpool.adapter() else VaultSyncAdapter(123 repo = vault,124 ourDeviceId = getOrCreateDeviceId(this),125 pairedDevices = deviceStore,126 pendingVouched = pendingVouchedStore,127 syncAlerts = syncAlertStore,128 )129 }130 } finally {131 VaultCrypto.zero(master)132 credentials?.keys?.secretKey?.let(VaultCrypto::zero)133 }134 }135136 companion object {137 fun start(context: Context) {138 val intent = Intent(context, PeachSyncService::class.java)139 startOrReportBlocked({ context.reportSyncOutcome(it) }) {140 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {141 context.startForegroundService(intent)142 } else {143 context.startService(intent)144 }145 }146 }147148 fun stop(context: Context) {149 context.stopService(Intent(context, PeachSyncService::class.java))150 }151 }152}153154internal data class ReconnectListenerCredentials(155 val keys: SyncIdentityKeypair,156 val identity: LocalDeviceIdentity,157)158159internal fun deriveUnlockedListenerCredentials(160 masterKey: ByteArray,161 deviceId: String,162 deviceName: String,163 relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,164): ReconnectListenerCredentials {165 val keys = deriveLocalSyncKeypair(masterKey, deviceId)166 val signing = deriveSyncSigningKeypair(masterKey, deviceId)167 val identity = LocalDeviceIdentity(168 deviceId = deviceId,169 deviceName = deviceName,170 deviceType = DeviceType.ANDROID,171 longTermPublicKeyHex = pubkeyToHex(keys.publicKey),172 signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),173 relayUrl = relayUrl,174 )175 if (!MessageDigest.isEqual(keys.publicKey, hexToPubkey(identity.longTermPublicKeyHex))) {176 VaultCrypto.zero(keys.secretKey)177 throw IllegalStateException("derived reconnect identity mismatch")178 }179 return ReconnectListenerCredentials(keys, identity)180}181182internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted183184internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"185internal const val SYNC_NOTIFICATION_ID = 4201186internal const val ACTION_RESTORE_SYNC_NOTIFICATION =187 "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"188189internal fun buildSyncNotification(context: Context): Notification {190 val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager191 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {192 manager.createNotificationChannel(193 NotificationChannel(194 SYNC_NOTIFICATION_CHANNEL_ID,195 "Peach sync",196 NotificationManager.IMPORTANCE_LOW,197 ).apply {198 description = "Shown while Peach is reachable for encrypted device sync."199 setShowBadge(false)200 },201 )202 }203 val open = PendingIntent.getActivity(204 context,205 0,206 Intent(context, MainActivity::class.java),207 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,208 )209 val restore = PendingIntent.getBroadcast(210 context,211 0,212 Intent(context, SyncNotificationDismissedReceiver::class.java)213 .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),214 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,215 )216 return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)217 .setContentTitle("Peach sync is active")218 .setContentText("Your other devices can reach this phone for encrypted sync.")219 .setSmallIcon(R.drawable.ic_launcher_monochrome)220 .setOngoing(true)221 .setPriority(NotificationCompat.PRIORITY_LOW)222 .setContentIntent(open)223 .setDeleteIntent(restore)224 .build()225 .also {226 227 228 229 230 it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR231 }232}233234internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =235 action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled236237238239240241242243class SyncNotificationDismissedReceiver : BroadcastReceiver() {244 override fun onReceive(context: Context, intent: Intent?) {245 if (!shouldRestoreSyncNotification(246 intent?.action,247 BackgroundSyncPreference.isEnabled(context),248 )249 ) {250 return251 }252 val manager =253 context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager254 manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))255 }256}257258259260261262263264265266267268269270internal fun startOrReportBlocked(271 report: (SyncOutcomeSummary) -> Unit,272 start: () -> Unit,273): Boolean = try {274 start()275 true276} catch (blocked: IllegalStateException) {277 report(278 SyncOutcomeSummary(279 reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,280 detail = blocked.message,281 ),282 )283 false284} catch (error: Exception) {285 286 287 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))288 false289}290291292293294295296297private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {298 runCatching {299 EntryPointAccessors300 .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)301 .syncOutcomes()302 .report(summary)303 }304}305306@EntryPoint307@InstallIn(SingletonComponent::class)308internal interface SyncOutcomeEntryPoint {309 fun syncOutcomes(): SyncOutcomeStore310}