feat: enhance SyncService and MainActivity for better battery optimization and service reliability
All checks were successful
Build / check-version (push) Successful in 6s
Build / build-linux (push) Successful in 5m32s
Build / build-windows (push) Successful in 8m23s
Build / build-android (push) Successful in 11m26s
Build / create-release (push) Has been skipped

This commit is contained in:
2026-02-05 07:48:09 +11:00
parent 89b0da0854
commit ece77ccba6
3 changed files with 200 additions and 33 deletions

View File

@@ -1,25 +1,73 @@
package wtf.ruv.paarrot package wtf.ruv.paarrot
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
companion object {
private const val TAG = "PaarrotMain"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Start the foreground sync service to keep the app alive for notifications // Start the foreground sync service to keep the app alive for notifications
startSyncService() startSyncService()
// Request battery optimization exemption for reliable background operation
requestBatteryOptimizationExemption()
}
override fun onResume() {
super.onResume()
// Ensure service is running when app comes to foreground
startSyncService()
} }
private fun startSyncService() { private fun startSyncService() {
val serviceIntent = Intent(this, SyncService::class.java) try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val serviceIntent = Intent(this, SyncService::class.java)
startForegroundService(serviceIntent) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
} else { startForegroundService(serviceIntent)
startService(serviceIntent) } else {
startService(serviceIntent)
}
Log.d(TAG, "SyncService started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start SyncService", e)
}
}
private fun requestBatteryOptimizationExemption() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
Log.d(TAG, "Requesting battery optimization exemption")
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to request battery optimization exemption", e)
// Try opening battery settings instead
try {
val settingsIntent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(settingsIntent)
} catch (e2: Exception) {
Log.e(TAG, "Failed to open battery settings", e2)
}
}
} else {
Log.d(TAG, "Already exempt from battery optimization")
}
} }
} }
} }

View File

@@ -7,12 +7,15 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
/** /**
@@ -21,20 +24,26 @@ import androidx.core.app.NotificationCompat
*/ */
class SyncService : Service() { class SyncService : Service() {
companion object { companion object {
private const val TAG = "PaarrotSync"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "paarrot_sync_channel" private const val CHANNEL_ID = "paarrot_sync_channel"
private const val CHANNEL_NAME = "Background Sync" private const val CHANNEL_NAME = "Background Sync"
private const val WAKE_LOCK_TAG = "Paarrot:SyncWakeLock" private const val WAKE_LOCK_TAG = "Paarrot:SyncWakeLock"
private const val ALARM_REQUEST_CODE = 1001 private const val ALARM_REQUEST_CODE = 1001
private const val KEEP_ALIVE_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes private const val KEEP_ALIVE_INTERVAL_MS = 4 * 60 * 1000L // 4 minutes (before 5 min Doze threshold)
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes max wake lock
} }
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var isRunning = false
private val keepAliveRunnable = object : Runnable { private val keepAliveRunnable = object : Runnable {
override fun run() { override fun run() {
// Re-acquire wake lock periodically to prevent it from being released if (!isRunning) return
Log.d(TAG, "Keep-alive tick - refreshing wake lock")
ensureWakeLock() ensureWakeLock()
refreshNotification()
handler.postDelayed(this, KEEP_ALIVE_INTERVAL_MS) handler.postDelayed(this, KEEP_ALIVE_INTERVAL_MS)
} }
} }
@@ -43,35 +52,103 @@ class SyncService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(TAG, "SyncService created")
isRunning = true
createNotificationChannel() createNotificationChannel()
acquireWakeLock() acquireWakeLock()
scheduleKeepAlive() scheduleKeepAlive()
requestBatteryOptimizationExemption()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "SyncService onStartCommand")
startForeground(NOTIFICATION_ID, createNotification()) startForeground(NOTIFICATION_ID, createNotification())
ensureWakeLock() ensureWakeLock()
return START_STICKY // Return REDELIVER_INTENT so the system restarts us with the last intent
return START_REDELIVER_INTENT
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "SyncService being destroyed - scheduling restart")
isRunning = false
handler.removeCallbacks(keepAliveRunnable) handler.removeCallbacks(keepAliveRunnable)
cancelAlarm()
// Schedule immediate restart
scheduleRestart()
releaseWakeLock() releaseWakeLock()
super.onDestroy() super.onDestroy()
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
// Restart service if app is swiped away Log.d(TAG, "Task removed - scheduling restart")
val restartServiceIntent = Intent(applicationContext, SyncService::class.java) scheduleRestart()
restartServiceIntent.setPackage(packageName) super.onTaskRemoved(rootIntent)
}
override fun onLowMemory() {
Log.w(TAG, "Low memory warning")
super.onLowMemory()
}
override fun onTrimMemory(level: Int) {
Log.d(TAG, "Trim memory level: $level")
super.onTrimMemory(level)
}
private fun scheduleRestart() {
// Try to restart the service immediately
val restartIntent = Intent(applicationContext, SyncService::class.java)
restartIntent.setPackage(packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(restartServiceIntent) try {
startForegroundService(restartIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to restart service directly", e)
// Fall back to alarm
scheduleAlarmRestart()
}
} else { } else {
startService(restartServiceIntent) startService(restartIntent)
}
}
private fun scheduleAlarmRestart() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, SyncService::class.java)
val pendingIntent = PendingIntent.getService(
this,
ALARM_REQUEST_CODE + 1,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Schedule restart in 1 second
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + 1000,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + 1000,
pendingIntent
)
}
}
private fun requestBatteryOptimizationExemption() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
Log.d(TAG, "Requesting battery optimization exemption")
// We can't directly request, but the app should prompt the user
// This is just a note that we need the exemption
}
} }
super.onTaskRemoved(rootIntent)
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
@@ -83,6 +160,8 @@ class SyncService : Service() {
).apply { ).apply {
description = "Keeps Paarrot connected for message notifications" description = "Keeps Paarrot connected for message notifications"
setShowBadge(false) setShowBadge(false)
enableLights(false)
enableVibration(false)
} }
val notificationManager = getSystemService(NotificationManager::class.java) val notificationManager = getSystemService(NotificationManager::class.java)
@@ -94,8 +173,10 @@ class SyncService : Service() {
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, MainActivity::class.java), Intent(this, MainActivity::class.java).apply {
PendingIntent.FLAG_IMMUTABLE flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
@@ -105,14 +186,18 @@ class SyncService : Service() {
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true) .setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build() .build()
} }
private fun scheduleKeepAlive() { private fun refreshNotification() {
// Use handler for periodic wake lock refresh val notificationManager = getSystemService(NotificationManager::class.java)
handler.postDelayed(keepAliveRunnable, KEEP_ALIVE_INTERVAL_MS) notificationManager.notify(NOTIFICATION_ID, createNotification())
}
// Also set up alarm as backup private fun scheduleKeepAlive() {
handler.postDelayed(keepAliveRunnable, KEEP_ALIVE_INTERVAL_MS)
scheduleAlarm() scheduleAlarm()
} }
@@ -126,13 +211,20 @@ class SyncService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
// Schedule inexact repeating alarm to restart service periodically // Use setExactAndAllowWhileIdle for better Doze mode support
alarmManager.setInexactRepeating( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
AlarmManager.ELAPSED_REALTIME_WAKEUP, alarmManager.setExactAndAllowWhileIdle(
SystemClock.elapsedRealtime() + KEEP_ALIVE_INTERVAL_MS, AlarmManager.ELAPSED_REALTIME_WAKEUP,
KEEP_ALIVE_INTERVAL_MS, SystemClock.elapsedRealtime() + KEEP_ALIVE_INTERVAL_MS,
pendingIntent pendingIntent
) )
} else {
alarmManager.setExact(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + KEEP_ALIVE_INTERVAL_MS,
pendingIntent
)
}
} }
private fun cancelAlarm() { private fun cancelAlarm() {
@@ -153,23 +245,32 @@ class SyncService : Service() {
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
WAKE_LOCK_TAG WAKE_LOCK_TAG
).apply { ).apply {
// Acquire indefinitely - the service manages its own lifecycle // Acquire with timeout to prevent battery drain if something goes wrong
acquire() acquire(WAKE_LOCK_TIMEOUT_MS)
} }
Log.d(TAG, "Wake lock acquired")
} }
private fun ensureWakeLock() { private fun ensureWakeLock() {
wakeLock?.let { wakeLock?.let {
if (!it.isHeld) { if (!it.isHeld) {
it.acquire() Log.d(TAG, "Wake lock was released, re-acquiring")
it.acquire(WAKE_LOCK_TIMEOUT_MS)
} }
} ?: acquireWakeLock() } ?: run {
Log.d(TAG, "Wake lock was null, creating new one")
acquireWakeLock()
}
// Re-schedule alarm each time we refresh
scheduleAlarm()
} }
private fun releaseWakeLock() { private fun releaseWakeLock() {
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
Log.d(TAG, "Wake lock released")
} }
} }
wakeLock = null wakeLock = null

View File

@@ -213,9 +213,27 @@ pub fn run() {
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.setup(|app| { .setup(|app| {
// Create the main window for mobile // Create the main window for mobile with navigation handler for external links
// Mobile uses the bundled assets directly, not localhost
WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.on_navigation(|url| {
let url_str = url.as_str();
// Allow navigation to app resources and special protocols
if url_str.starts_with("tauri://")
|| url_str.starts_with("http://tauri.localhost")
|| url_str.starts_with("https://tauri.localhost")
|| url_str.starts_with("blob:")
|| url_str.starts_with("data:")
|| url_str.starts_with("about:")
{
return true;
}
// External URLs - open in default browser
if url_str.starts_with("http://") || url_str.starts_with("https://") {
let _ = tauri_plugin_opener::open_url(url_str, None::<&str>);
return false;
}
true
})
.build()?; .build()?;
Ok(()) Ok(())
}) })