diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index cf85f28f..9bf60e4e 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -16,19 +16,33 @@ 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" 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 + +internal fun shouldOpenActivityViewImmediately(openActivityView: Boolean, isResumed: Boolean): Boolean = + openActivityView && isResumed + class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, WebUIFragment.OnFragmentInteractionListener { @@ -104,13 +118,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,11 +128,68 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte } }) + if (savedInstanceState != null) { + 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) + + 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 { + 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() + if (replace) { + transaction.replace(R.id.fragment_container, fragment) + } else { + transaction.add(R.id.fragment_container, fragment) + } + transaction.commit() + } + + 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. val usw = UsageStatsWatcher(this) @@ -168,7 +232,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..5c4ad286 --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/MainActivityNavigationTest.kt @@ -0,0 +1,35 @@ +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 { + @Test + fun initialWebUiUrl_defaultsToDashboardHome() { + assertEquals(baseURL, initialWebUiUrl(openActivityView = false)) + } + + @Test + fun initialWebUiUrl_opensActivityNavDestination() { + assertEquals( + "$baseURL/#/activity/unknown/", + initialWebUiUrl(openActivityView = true), + ) + } + + @Test + fun notificationIntent_navigatesImmediatelyWhenActivityIsResumed() { + assertTrue( + shouldOpenActivityViewImmediately(openActivityView = true, isResumed = true), + ) + } + + @Test + fun notificationIntent_defersNavigationWhenActivityIsStopped() { + assertFalse( + shouldOpenActivityViewImmediately(openActivityView = true, isResumed = false), + ) + } +}