package org.meshbay.client.photos import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.net.wifi.WifiManager import android.os.Build import android.os.IBinder import android.os.PowerManager import org.meshbay.client.R /** * Keeps a photo backup going with the screen off — the same pair as a cast * (CastService): this service holds the process, the CPU and the Wi-Fi, and * the shell keeps the WebView reported visible, because the sending happens in * the page and Chromium freezes a hidden page after 60 s. * * Its own service, of type dataSync, rather than a second reason on the cast * service: music can play during a backup, and each stops on its own. * * Started only from the page while the application is in front — a foreground * service cannot be started from the background on Android 12+ — and its * progress line is updated through the notification, which needs no start. */ class BackupService : Service() { private var wake: PowerManager.WakeLock? = null private var wifi: WifiManager.WifiLock? = null override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val n = notification(this, intent?.getStringExtra(EXTRA_TEXT) ?: "") if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) else startForeground(ID, n) if (wake == null) { wake = getSystemService(PowerManager::class.java) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:backup").apply { acquire(MAX_HOLD_MS) } @Suppress("DEPRECATION") val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) .createWifiLock(mode, "meshbay:backup").apply { acquire() } } return START_NOT_STICKY } /** * Android 15 gives dataSync six hours a day and then calls this; not * stopping here is a crash. The run carries on with the screen on, or at * the next opening, which is where an interrupted run goes anyway. */ override fun onTimeout(startId: Int, fgsType: Int) { stopSelf() } override fun onDestroy() { wake?.let { if (it.isHeld) it.release() } wifi?.let { if (it.isHeld) it.release() } wake = null; wifi = null super.onDestroy() } companion object { private const val ID = 8 const val EXTRA_TEXT = "text" private const val MAX_HOLD_MS = 6L * 3600 * 1000 fun notification(context: Context, text: String): Notification { PhotoChannels.ensureChannel(context) val open = PendingIntent.getActivity(context, ID, context.packageManager.getLaunchIntentForPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) return Notification.Builder(context, PhotoChannels.CHANNEL) .setContentTitle("MeshBay") .setContentText(text) .setSmallIcon(R.drawable.ic_notify) .setContentIntent(open) .setOngoing(true) .setOnlyAlertOnce(true) .build() } /** The progress line of a running backup; nothing when none runs. */ fun update(context: Context, text: String) { val nm = context.getSystemService(NotificationManager::class.java) if (nm.activeNotifications.any { it.id == ID }) nm.notify(ID, notification(context, text)) } } }