Skip to content

Commit d1ed166

Browse files
committed
feat: update widget every 5 mins
1 parent 2c5369a commit d1ed166

8 files changed

Lines changed: 453 additions & 30 deletions

File tree

mobile/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
tools:ignore="ProtectedPermissions" />
1717
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1818
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
19+
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
1920
<meta-data android:name="com.samsung.android.vr.application.mode" android:value="dual" />
2021

2122
<application

mobile/src/main/java/net/activitywatch/android/MainActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
8080
})
8181

8282
// Test RustInterface functions (remove after testing)
83-
RustInterface(this).test()
8483
}
8584

8685
override fun onResume() {

mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ class PermissionsFragment : Fragment() {
147147
view.findViewById<Button>(R.id.btnGrantAccessibilityPermission).setOnClickListener {
148148
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
149149
}
150+
// Handle grant exact alarm permissions (for widgets)
151+
view.findViewById<Button>(R.id.btnGrantExactAlarmPermission).setOnClickListener {
152+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
153+
startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
154+
data = android.net.Uri.parse("package:" + requireContext().packageName)
155+
})
156+
}
157+
}
150158
// Handle grant notification access (for media watcher)
151159
view.findViewById<Button>(R.id.btnGrantNotificationPermission).setOnClickListener {
152160
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
@@ -161,15 +169,23 @@ class PermissionsFragment : Fragment() {
161169
val usagePermissionGranted = UsageStatsWatcher.isUsageAllowed(requireContext())
162170
val accessibilityPermissionGranted = UsageStatsWatcher.isAccessibilityAllowed(requireContext())
163171
val notificationPermissionGranted = MediaWatcher.isNotificationAccessGranted(requireContext())
172+
val exactAlarmPermissionGranted = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
173+
val alarmManager = requireContext().getSystemService(android.content.Context.ALARM_SERVICE) as android.app.AlarmManager
174+
alarmManager.canScheduleExactAlarms()
175+
} else {
176+
true // Granted on Android < 12 by manifest permission
177+
}
164178

165179
// Disable buttons if permissions granted
166180
view?.findViewById<Button>(R.id.btnGrantUsagePermission)?.isEnabled = !usagePermissionGranted
167181
view?.findViewById<Button>(R.id.btnGrantAccessibilityPermission)?.isEnabled = !accessibilityPermissionGranted
182+
view?.findViewById<Button>(R.id.btnGrantExactAlarmPermission)?.isEnabled = !exactAlarmPermissionGranted
168183
view?.findViewById<Button>(R.id.btnGrantNotificationPermission)?.isEnabled = !notificationPermissionGranted
169184

170185
// Set the checkbox/x mark based on the permission status
171186
view?.findViewById<ImageView>(R.id.checkmarkUsage)?.setImageResource(if(usagePermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
172187
view?.findViewById<ImageView>(R.id.checkmarkAccessibility)?.setImageResource(if(accessibilityPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
188+
view?.findViewById<ImageView>(R.id.checkmarkExactAlarm)?.setImageResource(if(exactAlarmPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
173189
view?.findViewById<ImageView>(R.id.checkmarkNotification)?.setImageResource(if(notificationPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
174190
}
175191
}

mobile/src/main/java/net/activitywatch/android/RustInterface.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ class RustInterface (context: Context? = null) {
148148
fun insertEvent(bucket_id: String, timestamp: Instant, duration: Double, data: JSONObject) {
149149
val event = Event(timestamp, duration, data)
150150
val msg = heartbeat(bucket_id, event.toString(), 0.0)
151+
Log.d(TAG, "insertEvent response: $msg")
151152
}
152153

153154
fun getBucketsJSON(): JSONObject {

mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ class WebWatcher : AccessibilityService() {
9797
}
9898
}
9999
} catch(ex : Exception) {
100-
Log.e(tag, ex.message!!)
100+
Log.e(tag, ex.message ?: ex.toString())
101101
}
102102
}
103103

mobile/src/main/java/net/activitywatch/android/widget/CategoryTimeWidgetProvider.kt

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
package net.activitywatch.android.widget
22

3+
import android.app.AlarmManager
34
import android.app.PendingIntent
45
import android.appwidget.AppWidgetManager
56
import android.appwidget.AppWidgetProvider
67
import android.content.ComponentName
78
import android.content.Context
89
import android.content.Intent
10+
import android.os.SystemClock
911
import android.util.Log
1012
import android.view.View
1113
import android.widget.RemoteViews
14+
import kotlinx.coroutines.CoroutineScope
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.launch
1217
import net.activitywatch.android.R
1318
import net.activitywatch.android.watcher.UsageStatsWatcher
1419

1520
private const val TAG = "CategoryTimeWidget"
1621
private const val ACTION_REFRESH = "net.activitywatch.android.widget.ACTION_REFRESH"
22+
const val ACTION_PERIODIC_UPDATE = "net.activitywatch.android.widget.ACTION_PERIODIC_UPDATE"
23+
private const val UPDATE_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
1724

1825
/**
1926
* Widget provider for displaying category time stats.
@@ -36,43 +43,61 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {
3643

3744
override fun onReceive(context: Context, intent: Intent) {
3845
super.onReceive(context, intent)
39-
40-
if (intent.action == ACTION_REFRESH) {
41-
Log.d(TAG, "Refresh button clicked - re-parsing usage events and updating widgets")
42-
43-
val appWidgetManager = AppWidgetManager.getInstance(context)
44-
val componentName = ComponentName(context, CategoryTimeWidgetProvider::class.java)
45-
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
46-
47-
// Show loading indicator first
48-
for (appWidgetId in appWidgetIds) {
49-
showLoadingState(context, appWidgetManager, appWidgetId)
50-
}
51-
52-
// Re-parse usage events
53-
try {
54-
val usageStatsWatcher = UsageStatsWatcher(context)
55-
usageStatsWatcher.sendHeartbeats()
56-
Log.d(TAG, "Triggered usage events re-parsing")
57-
} catch (e: Exception) {
58-
Log.e(TAG, "Error re-parsing usage events", e)
46+
when (intent.action) {
47+
ACTION_REFRESH -> {
48+
Log.d(TAG, "Refresh button clicked - re-parsing usage events and updating widgets")
49+
50+
val appWidgetManager = AppWidgetManager.getInstance(context)
51+
val componentName = ComponentName(context, CategoryTimeWidgetProvider::class.java)
52+
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
53+
54+
// Show loading indicator first
55+
for (appWidgetId in appWidgetIds) {
56+
showLoadingState(context, appWidgetManager, appWidgetId)
57+
}
58+
59+
// Re-parse usage events
60+
try {
61+
val usageStatsWatcher = UsageStatsWatcher(context)
62+
usageStatsWatcher.sendHeartbeats()
63+
Log.d(TAG, "Triggered usage events re-parsing")
64+
} catch (e: Exception) {
65+
Log.e(TAG, "Error re-parsing usage events", e)
66+
}
67+
68+
// Then update with fresh data
69+
for (appWidgetId in appWidgetIds) {
70+
updateWidgetWithRefreshButton(context, appWidgetManager, appWidgetId)
71+
}
5972
}
60-
61-
// Then update with fresh data
62-
for (appWidgetId in appWidgetIds) {
63-
updateWidgetWithRefreshButton(context, appWidgetManager, appWidgetId)
73+
ACTION_PERIODIC_UPDATE -> {
74+
Log.d(TAG, "Periodic update triggered by AlarmManager")
75+
val pendingResult = goAsync()
76+
CoroutineScope(Dispatchers.IO).launch {
77+
try {
78+
CategoryTimeWidgetUpdater.updateAllWidgets(context)
79+
} catch (e: Exception) {
80+
Log.e(TAG, "Error during periodic update", e)
81+
} finally {
82+
// Reschedule the next exact alarm
83+
schedulePeriodicUpdates(context)
84+
pendingResult.finish()
85+
}
86+
}
6487
}
6588
}
6689
}
6790

6891
override fun onEnabled(context: Context) {
69-
Log.d(TAG, "Widget enabled - scheduling 30-minute background updates")
70-
CategoryTimeWidgetWorker.schedulePeriodicUpdates(context)
92+
super.onEnabled(context)
93+
Log.d(TAG, "Widget enabled - scheduling 5-minute background updates")
94+
schedulePeriodicUpdates(context)
7195
}
7296

7397
override fun onDisabled(context: Context) {
98+
super.onDisabled(context)
7499
Log.d(TAG, "Widget disabled - cancelling background updates")
75-
CategoryTimeWidgetWorker.cancelPeriodicUpdates(context)
100+
cancelPeriodicUpdates(context)
76101
}
77102

78103
companion object {
@@ -99,7 +124,50 @@ class CategoryTimeWidgetProvider : AppWidgetProvider() {
99124
appWidgetId: Int
100125
) {
101126
// updateSingleWidget handles data, click handler, and loading state
102-
CategoryTimeWidgetWorker.updateSingleWidget(context, appWidgetManager, appWidgetId)
127+
CategoryTimeWidgetUpdater.updateSingleWidget(context, appWidgetManager, appWidgetId)
128+
}
129+
130+
private fun getUpdateIntent(context: Context): PendingIntent {
131+
val intent = Intent(context, CategoryTimeWidgetProvider::class.java).apply {
132+
action = ACTION_PERIODIC_UPDATE
133+
}
134+
return PendingIntent.getBroadcast(
135+
context,
136+
1,
137+
intent,
138+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
139+
)
140+
}
141+
142+
fun schedulePeriodicUpdates(context: Context) {
143+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
144+
val pendingIntent = getUpdateIntent(context)
145+
146+
// Start exact alarm that fires once
147+
try {
148+
alarmManager.setExactAndAllowWhileIdle(
149+
AlarmManager.ELAPSED_REALTIME_WAKEUP,
150+
SystemClock.elapsedRealtime() + UPDATE_INTERVAL_MS,
151+
pendingIntent
152+
)
153+
Log.d(TAG, "Scheduled exact AlarmManager update in 5 minutes")
154+
} catch (e: SecurityException) {
155+
// In Android 14+, SCHEDULE_EXACT_ALARM might be revoked by user
156+
Log.e(TAG, "Cannot schedule exact alarm without permission, falling back to inexact", e)
157+
alarmManager.setInexactRepeating(
158+
AlarmManager.ELAPSED_REALTIME_WAKEUP,
159+
SystemClock.elapsedRealtime() + UPDATE_INTERVAL_MS,
160+
UPDATE_INTERVAL_MS,
161+
pendingIntent
162+
)
163+
}
164+
}
165+
166+
fun cancelPeriodicUpdates(context: Context) {
167+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
168+
val pendingIntent = getUpdateIntent(context)
169+
alarmManager.cancel(pendingIntent)
170+
Log.d(TAG, "Cancelled AlarmManager periodic updates")
103171
}
104172
}
105173
}

0 commit comments

Comments
 (0)