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.CancellationException24import kotlinx.coroutines.CoroutineScope25import kotlinx.coroutines.SupervisorJob26import kotlinx.coroutines.cancel27import kotlinx.coroutines.launch28import java.security.MessageDigest29import javax.inject.Inject3031323334353637383940@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: SyncAlertStore48 @Inject lateinit var syncOutcomes: SyncOutcomeStore49 @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore50 @Inject lateinit var syncGeneration: SyncGenerationStore5152 private val scope = CoroutineScope(SupervisorJob())5354 override fun onBind(intent: Intent?): IBinder? = null5556 57585960 private fun syncRefusal(): SyncOutcomeSummary? =61 syncRefusalReason(syncGeneration, System.currentTimeMillis())62 ?.let { SyncOutcomeSummary(reason = it) }6364 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {65 66 67 syncRefusal()?.let {68 syncOutcomes.report(it)69 stopSelf()70 return START_NOT_STICKY71 }72 73 74 if (vault.isUnlocked.value && vault.syncVaultId() == null) {75 stopSelf()76 return START_NOT_STICKY77 }78 if (!startOrReportBlocked(syncOutcomes::report) {79 startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))80 }) {81 stopSelf()82 return START_NOT_STICKY83 }84 if (vault.isUnlocked.value) {85 scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }86 }87 val listenerStarted = startReconnectListener()88 cloudRelayListener.requestReconcile(89 refreshExisting = shouldRefreshExistingWaiters(listenerStarted),90 )91 return START_STICKY92 }9394 override fun onDestroy() {95 cloudRelayListener.stop()96 scope.cancel()97 super.onDestroy()98 }99100 101 private fun startReconnectListener(): Boolean {102 val master = vault.syncMasterKey()103 var credentials: ReconnectListenerCredentials? = null104 return try {105 credentials = if (master != null) {106 lockedSyncSpool.refresh(vault)107 deriveUnlockedListenerCredentials(108 credentials = loadOrCreateLocalIdentity(this),109 deviceName = getDeviceName(this),110 relayUrl = RelaySettingsStore.readRelayUrl(this),111 )112 } else {113 lockedSyncSpool.identity()?.let { ReconnectListenerCredentials(it.first, it.second) }114 ?: return false115 }116 cloudRelayListener.start(117 scope,118 credentials.keys.secretKey,119 credentials.keys.publicKey,120 credentials.identity,121 ) {122 123 124 125 when {126 vault.syncVaultId() == null -> throw CancellationException("decoy session")127 128 129 130 131 else -> syncRefusal()?.let { refusal ->132 syncOutcomes.report(refusal)133 null134 } ?: if (!vault.isUnlocked.value) {135 lockedSyncSpool.adapter()136 } else {137 val credentials = loadOrCreateLocalIdentity(this)138 VaultSyncAdapter(139 repo = vault,140 ourDeviceId = credentials.deviceId,141 pairedDevices = deviceStore,142 pendingVouched = pendingVouchedStore,143 syncAlerts = syncAlertStore,144 localCredentials = credentials,145 )146 }147 }148 }149 } finally {150 master?.let(VaultCrypto::zero)151 credentials?.keys?.secretKey?.let(VaultCrypto::zero)152 }153 }154155 companion object {156 fun start(context: Context) {157 val intent = Intent(context, PeachSyncService::class.java)158 startOrReportBlocked({ context.reportSyncOutcome(it) }) {159 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {160 context.startForegroundService(intent)161 } else {162 context.startService(intent)163 }164 }165 }166167 fun stop(context: Context) {168 context.stopService(Intent(context, PeachSyncService::class.java))169 }170 }171}172173internal data class ReconnectListenerCredentials(174 val keys: SyncIdentityKeypair,175 val identity: LocalDeviceIdentity,176)177178internal fun deriveUnlockedListenerCredentials(179 credentials: LocalIdentityCredentials,180 deviceName: String,181 relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,182): ReconnectListenerCredentials {183 val keys = localSyncKeypair(credentials)184 val identity = LocalDeviceIdentity(185 deviceId = credentials.deviceId,186 deviceName = deviceName,187 deviceType = DeviceType.ANDROID,188 longTermPublicKeyHex = pubkeyToHex(keys.publicKey),189 signingPublicKeyHex = pubkeyToHex(credentials.ed25519PublicKey),190 relayUrl = relayUrl,191 )192 return ReconnectListenerCredentials(keys, identity)193}194195internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted196197internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"198internal const val SYNC_NOTIFICATION_ID = 4201199internal const val ACTION_RESTORE_SYNC_NOTIFICATION =200 "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"201202internal fun buildSyncNotification(context: Context): Notification {203 val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager204 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {205 manager.createNotificationChannel(206 NotificationChannel(207 SYNC_NOTIFICATION_CHANNEL_ID,208 "Peach sync",209 NotificationManager.IMPORTANCE_LOW,210 ).apply {211 description = "Shown while Peach is reachable for encrypted device sync."212 setShowBadge(false)213 },214 )215 }216 val open = PendingIntent.getActivity(217 context,218 0,219 Intent(context, MainActivity::class.java),220 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,221 )222 val restore = PendingIntent.getBroadcast(223 context,224 0,225 Intent(context, SyncNotificationDismissedReceiver::class.java)226 .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),227 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,228 )229 return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)230 .setContentTitle("Peach sync is on")231 .setContentText("Checking for encrypted changes from your paired devices.")232 .setSmallIcon(R.drawable.ic_launcher_monochrome)233 .setOngoing(true)234 .setPriority(NotificationCompat.PRIORITY_LOW)235 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)236 .setContentIntent(open)237 .setDeleteIntent(restore)238 .build()239 .also {240 241 242 243 244 it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR245 }246}247248internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =249 action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled250251252253254255256257class SyncNotificationDismissedReceiver : BroadcastReceiver() {258 override fun onReceive(context: Context, intent: Intent?) {259 if (!shouldRestoreSyncNotification(260 intent?.action,261 BackgroundSyncPreference.isEnabled(context),262 )263 ) {264 return265 }266 val manager =267 context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager268 manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))269 }270}271272273274275276277278279280281282283284internal fun startOrReportBlocked(285 report: (SyncOutcomeSummary) -> Unit,286 start: () -> Unit,287): Boolean = try {288 start()289 true290} catch (blocked: IllegalStateException) {291 report(292 SyncOutcomeSummary(293 reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,294 detail = blocked.message,295 ),296 )297 false298} catch (error: Exception) {299 300 301 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))302 false303}304305306307308309310311private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {312 runCatching {313 EntryPointAccessors314 .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)315 .syncOutcomes()316 .report(summary)317 }318}319320@EntryPoint321@InstallIn(SingletonComponent::class)322internal interface SyncOutcomeEntryPoint {323 fun syncOutcomes(): SyncOutcomeStore324}