From 121852be4ec6e2a39b250f70f3e674669b818e08 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 27 Aug 2026 15:34:54 +0000 Subject: [PATCH 1/4] fix(notify): open the activity view when an alert is tapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #225 already attached a MainActivity PendingIntent, so the notification no longer did nothing — but the extra was missing, so a tap still landed on dashboard home instead of the Activity drawer destination (/#/activity/unknown/). Pass EXTRA_OPEN_ACTIVITY_VIEW, honor it on cold start and onNewIntent (SINGLE_TOP), and share the URL with the drawer item so the two cannot drift. --- .../net/activitywatch/android/MainActivity.kt | 45 +++++++++++++++---- .../android/workers/NotifyWorker.kt | 10 ++++- .../android/MainActivityNavigationTest.kt | 19 ++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index cf85f28f..55bc5be3 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -29,6 +29,16 @@ private const val TAG = "MainActivity" const val baseURL = "http://127.0.0.1:5600" +// Same destination as the drawer "Activity" item. Notification taps set +// EXTRA_OPEN_ACTIVITY_VIEW so we land here instead of dashboard home. +const val ACTIVITY_VIEW_ROUTE = "/#/activity/unknown/" +const val EXTRA_OPEN_ACTIVITY_VIEW = "net.activitywatch.android.extra.OPEN_ACTIVITY_VIEW" + +internal fun activityViewUrl(baseUrl: String = baseURL): String = "$baseUrl$ACTIVITY_VIEW_ROUTE" + +internal fun initialWebUiUrl(openActivityView: Boolean, baseUrl: String = baseURL): String = + if (openActivityView) activityViewUrl(baseUrl) else baseUrl + class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener { @@ -104,13 +114,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val serviceIntent = Intent(this, BackgroundService::class.java) startForegroundService(serviceIntent) - if (savedInstanceState != null) { - return - } - val firstFragment = WebUIFragment.newInstance(authenticatedUrl()) - supportFragmentManager.beginTransaction() - .add(R.id.fragment_container, firstFragment).commit() - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { @@ -121,6 +124,32 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } }) + if (savedInstanceState != null) { + return + } + showWebUi( + initialWebUiUrl(intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false)), + replace = false, + ) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false)) { + showWebUi(activityViewUrl(), replace = true) + } + } + + private fun showWebUi(url: String, replace: Boolean) { + val fragment = WebUIFragment.newInstance(authenticatedUrl(url)) + val transaction = supportFragmentManager.beginTransaction() + if (replace) { + transaction.replace(R.id.fragment_container, fragment) + } else { + transaction.add(R.id.fragment_container, fragment) + } + transaction.commit() } override fun onResume() { @@ -168,7 +197,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } R.id.nav_activity -> { fragmentClass = WebUIFragment::class.java - url = authenticatedUrl("$baseURL/#/activity/unknown/") + url = authenticatedUrl(activityViewUrl()) } R.id.nav_buckets -> { fragmentClass = WebUIFragment::class.java diff --git a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt index b746d184..6f3f72ca 100644 --- a/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt +++ b/mobile/src/main/java/net/activitywatch/android/workers/NotifyWorker.kt @@ -11,6 +11,7 @@ import androidx.core.app.NotificationCompat import androidx.work.Worker import androidx.work.WorkerParameters import com.jakewharton.threetenabp.AndroidThreeTen +import net.activitywatch.android.EXTRA_OPEN_ACTIVITY_VIEW import net.activitywatch.android.MainActivity import net.activitywatch.android.R import net.activitywatch.android.RustInterface @@ -209,9 +210,14 @@ class NotifyWorker(context: Context, params: WorkerParameters) : Worker(context, val body = "${alert.label}: $thresholdStr" + if (thresholdStr != actualStr) " ($actualStr)" else "" - // Open the activity/timeline view in MainActivity when the notification is tapped. + // Open the activity/timeline view (same destination as the drawer + // "Activity" item) when the notification is tapped. SINGLE_TOP lets an + // already-running MainActivity receive onNewIntent instead of stacking. val openIntent = Intent(applicationContext, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(EXTRA_OPEN_ACTIVITY_VIEW, true) } val pendingIntent = PendingIntent.getActivity( applicationContext, diff --git a/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt new file mode 100644 index 00000000..80422db3 --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt @@ -0,0 +1,19 @@ +package net.activitywatch.android + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MainActivityNavigationTest { + @Test + fun initialWebUiUrl_defaultsToDashboardHome() { + assertEquals(baseURL, initialWebUiUrl(openActivityView = false)) + } + + @Test + fun initialWebUiUrl_opensActivityNavDestination() { + assertEquals( + "$baseURL/#/activity/unknown/", + initialWebUiUrl(openActivityView = true), + ) + } +} From 0a2738d9829f076feb41e97153ab5b08c8d2671e Mon Sep 17 00:00:00 2001 From: test Date: Thu, 27 Aug 2026 15:42:37 +0000 Subject: [PATCH 2/4] fix(notify): honor activity-view extra after process death If the process was killed, onCreate restores the previous fragment and used to return before reading EXTRA_OPEN_ACTIVITY_VIEW, so a notification tap could land on dashboard home. Replace the restored fragment when the extra is set, and consume it so a later rotation does not reload the WebView. --- .../net/activitywatch/android/MainActivity.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 55bc5be3..52ad72da 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -124,23 +124,35 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } }) + // Honor a notification tap even when the system restores a previous + // fragment (process death). Consume the extra so a later rotation + // does not replace the restored WebView with a fresh one. + val openActivityView = takeOpenActivityView(intent) if (savedInstanceState != null) { + if (openActivityView) { + showWebUi(activityViewUrl(), replace = true) + } return } - showWebUi( - initialWebUiUrl(intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false)), - replace = false, - ) + showWebUi(initialWebUiUrl(openActivityView), replace = false) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - if (intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false)) { + if (takeOpenActivityView(intent)) { showWebUi(activityViewUrl(), replace = true) } } + private fun takeOpenActivityView(intent: Intent): Boolean { + val open = intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false) + if (open) { + intent.removeExtra(EXTRA_OPEN_ACTIVITY_VIEW) + } + return open + } + private fun showWebUi(url: String, replace: Boolean) { val fragment = WebUIFragment.newInstance(authenticatedUrl(url)) val transaction = supportFragmentManager.beginTransaction() From e79abeca4145b5e1327e5ad832c95eea6b1159d0 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 27 Aug 2026 15:50:32 +0000 Subject: [PATCH 3/4] fix(notify): defer activity-view replace until onResume onNewIntent runs while MainActivity is still stopped, after onSaveInstanceState. commit() there throws IllegalStateException when the user taps a notification on a backgrounded app. Stash the intent and replace the fragment in onResume, which is also the process-death restore path. --- .../net/activitywatch/android/MainActivity.kt | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 52ad72da..e0fe5c4d 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -124,25 +124,25 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } }) - // Honor a notification tap even when the system restores a previous - // fragment (process death). Consume the extra so a later rotation - // does not replace the restored WebView with a fresh one. - val openActivityView = takeOpenActivityView(intent) if (savedInstanceState != null) { - if (openActivityView) { - showWebUi(activityViewUrl(), replace = true) - } return } + // Cold start: pick the right first fragment so we don't flash dashboard + // home before onResume. Consume the extra here; onResume is the path + // for reused instances (onNewIntent) and process-death restore. + val openActivityView = intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false) showWebUi(initialWebUiUrl(openActivityView), replace = false) + if (openActivityView) { + intent.removeExtra(EXTRA_OPEN_ACTIVITY_VIEW) + } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - if (takeOpenActivityView(intent)) { - showWebUi(activityViewUrl(), replace = true) - } + // Do not commit fragments here. onNewIntent runs while the activity is + // still stopped (after onSaveInstanceState); commit() would throw. + // onResume performs the replace once the FragmentManager is ready. } private fun takeOpenActivityView(intent: Intent): Boolean { @@ -167,6 +167,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte override fun onResume() { super.onResume() + // Notification tap on a reused/restored instance: replace after the + // FragmentManager is ready. Cold start already consumed the extra in + // onCreate, so this is a no-op there. + if (takeOpenActivityView(intent)) { + showWebUi(activityViewUrl(), replace = true) + } + // Ensures data is always fresh when app is opened, // even if it was up to an hour since the last logging-alarm was triggered. val usw = UsageStatsWatcher(this) From f7321a8a10185dc639d11f273a7fa318b2ce2495 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 27 Aug 2026 16:07:55 +0000 Subject: [PATCH 4/4] fix(notify): handle taps while activity is resumed --- .../net/activitywatch/android/MainActivity.kt | 38 +++++++++++++------ .../android/MainActivityNavigationTest.kt | 16 ++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index e0fe5c4d..9bf60e4e 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -16,13 +16,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch import net.activitywatch.android.databinding.ActivityMainBinding import net.activitywatch.android.fragments.TestFragment import net.activitywatch.android.fragments.WebUIFragment -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch import net.activitywatch.android.watcher.UsageStatsWatcher private const val TAG = "MainActivity" @@ -39,6 +40,9 @@ internal fun activityViewUrl(baseUrl: String = baseURL): String = "$baseUrl$ACTI internal fun initialWebUiUrl(openActivityView: Boolean, baseUrl: String = baseURL): String = if (openActivityView) activityViewUrl(baseUrl) else baseUrl +internal fun shouldOpenActivityViewImmediately(openActivityView: Boolean, isResumed: Boolean): Boolean = + openActivityView && isResumed + class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener { @@ -140,9 +144,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - // Do not commit fragments here. onNewIntent runs while the activity is - // still stopped (after onSaveInstanceState); commit() would throw. - // onResume performs the replace once the FragmentManager is ready. + + val isResumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + if (shouldOpenActivityViewImmediately( + intent.getBooleanExtra(EXTRA_OPEN_ACTIVITY_VIEW, false), + isResumed, + ) + ) { + openPendingActivityView() + } + // A stopped activity cannot safely commit here because its fragment + // state may already be saved. onResume consumes the intent instead. } private fun takeOpenActivityView(intent: Intent): Boolean { @@ -164,15 +176,19 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte transaction.commit() } - override fun onResume() { - super.onResume() - - // Notification tap on a reused/restored instance: replace after the - // FragmentManager is ready. Cold start already consumed the extra in - // onCreate, so this is a no-op there. + private fun openPendingActivityView() { if (takeOpenActivityView(intent)) { showWebUi(activityViewUrl(), replace = true) } + } + + override fun onResume() { + super.onResume() + + // Notification tap on a stopped/restored instance: replace after the + // FragmentManager is ready. Cold start and resumed delivery already + // consumed the extra, so this is a no-op in those cases. + openPendingActivityView() // Ensures data is always fresh when app is opened, // even if it was up to an hour since the last logging-alarm was triggered. diff --git a/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt index 80422db3..5c4ad286 100644 --- a/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt @@ -1,6 +1,8 @@ package net.activitywatch.android import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class MainActivityNavigationTest { @@ -16,4 +18,18 @@ class MainActivityNavigationTest { initialWebUiUrl(openActivityView = true), ) } + + @Test + fun notificationIntent_navigatesImmediatelyWhenActivityIsResumed() { + assertTrue( + shouldOpenActivityViewImmediately(openActivityView = true, isResumed = true), + ) + } + + @Test + fun notificationIntent_defersNavigationWhenActivityIsStopped() { + assertFalse( + shouldOpenActivityViewImmediately(openActivityView = true, isResumed = false), + ) + } }