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 javax.inject.Inject2829303132333435363738@AndroidEntryPoint39class PeachSyncService : Service() {4041 @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener42 @Inject lateinit var vault: VaultRepository43 @Inject lateinit var deviceStore: PairedDeviceStore44 @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore45 @Inject lateinit var syncAlertStore: SyncAlertStore46 @Inject lateinit var syncOutcomes: SyncOutcomeStore47 @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore4849 private val scope = CoroutineScope(SupervisorJob())5051 override fun onBind(intent: Intent?): IBinder? = null5253 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {54 55 56 57 58 59 60 if (!startOrReportBlocked(syncOutcomes::report) {61 startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))62 }63 ) {64 stopSelf()65 return START_NOT_STICKY66 }67 if (vault.isUnlocked.value) {68 scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }69 }70 val listenerStarted = startReconnectListener()71 72 73 74 75 76 77 78 cloudRelayListener.requestReconcile(79 refreshExisting = shouldRefreshExistingWaiters(listenerStarted),80 )81 82 83 84 return START_STICKY85 }8687 override fun onDestroy() {88 cloudRelayListener.stop()89 scope.cancel()90 super.onDestroy()91 }9293 949596979899100101102103104 private fun startReconnectListener(): Boolean {105 val master = vault.syncMasterKey()106 return try {107 if (master != null) lockedSyncSpool.refresh(vault)108 val (keys, identity) = lockedSyncSpool.identity() ?: return false109 cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {110 if (!vault.isUnlocked.value) lockedSyncSpool.adapter() else VaultSyncAdapter(111 repo = vault,112 ourDeviceId = getOrCreateDeviceId(this),113 pairedDevices = deviceStore,114 pendingVouched = pendingVouchedStore,115 syncAlerts = syncAlertStore,116 )117 }118 } finally {119 master?.let(VaultCrypto::zero)120 }121 }122123 companion object {124 fun start(context: Context) {125 val intent = Intent(context, PeachSyncService::class.java)126 startOrReportBlocked({ context.reportSyncOutcome(it) }) {127 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {128 context.startForegroundService(intent)129 } else {130 context.startService(intent)131 }132 }133 }134135 fun stop(context: Context) {136 context.stopService(Intent(context, PeachSyncService::class.java))137 }138 }139}140141internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted142143internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"144internal const val SYNC_NOTIFICATION_ID = 4201145internal const val ACTION_RESTORE_SYNC_NOTIFICATION =146 "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"147148internal fun buildSyncNotification(context: Context): Notification {149 val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager150 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {151 manager.createNotificationChannel(152 NotificationChannel(153 SYNC_NOTIFICATION_CHANNEL_ID,154 "Peach sync",155 NotificationManager.IMPORTANCE_LOW,156 ).apply {157 description = "Shown while Peach is reachable for encrypted device sync."158 setShowBadge(false)159 },160 )161 }162 val open = PendingIntent.getActivity(163 context,164 0,165 Intent(context, MainActivity::class.java),166 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,167 )168 val restore = PendingIntent.getBroadcast(169 context,170 0,171 Intent(context, SyncNotificationDismissedReceiver::class.java)172 .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),173 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,174 )175 return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)176 .setContentTitle("Peach sync is active")177 .setContentText("Your other devices can reach this phone for encrypted sync.")178 .setSmallIcon(R.drawable.ic_launcher_monochrome)179 .setOngoing(true)180 .setPriority(NotificationCompat.PRIORITY_LOW)181 .setContentIntent(open)182 .setDeleteIntent(restore)183 .build()184 .also {185 186 187 188 189 it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR190 }191}192193internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =194 action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled195196197198199200201202class SyncNotificationDismissedReceiver : BroadcastReceiver() {203 override fun onReceive(context: Context, intent: Intent?) {204 if (!shouldRestoreSyncNotification(205 intent?.action,206 BackgroundSyncPreference.isEnabled(context),207 )208 ) {209 return210 }211 val manager =212 context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager213 manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))214 }215}216217218219220221222223224225226227228229internal fun startOrReportBlocked(230 report: (SyncOutcomeSummary) -> Unit,231 start: () -> Unit,232): Boolean = try {233 start()234 true235} catch (blocked: IllegalStateException) {236 report(237 SyncOutcomeSummary(238 reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,239 detail = blocked.message,240 ),241 )242 false243} catch (error: Exception) {244 245 246 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))247 false248}249250251252253254255256private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {257 runCatching {258 EntryPointAccessors259 .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)260 .syncOutcomes()261 .report(summary)262 }263}264265@EntryPoint266@InstallIn(SingletonComponent::class)267internal interface SyncOutcomeEntryPoint {268 fun syncOutcomes(): SyncOutcomeStore269}