From 86062e7ac0ac1d3386217596c88eab244f7542bc Mon Sep 17 00:00:00 2001 From: jimturner-adfa Date: Tue, 29 Sep 2026 11:35:37 -0700 Subject: [PATCH] ADFA-5646: Hide the discover-plugins download arrow while offline The Extensions Manager's discover-plugins action opens a URL with no offline fallback, so tapping it while offline was a dead end. Hide it instead of letting the user find that out the hard way. Adds ConnectivityObserver (common module): a ConnectivityManager.NetworkCallback wrapped in a callbackFlow, re-deriving from the existing Context.isNetworkConnected() on every callback so a Wi-Fi -> cellular handover isn't misreported as offline. PluginManagerViewModel collects it into a new PluginManagerUiState.isOnline field (defaults true to avoid flash-hiding the icon on a connected cold start), and ManagerScreen gates the icon on it. Verified on-device: toggling Wi-Fi/mobile data off hides the icon; toggling back on brings it back live, with no navigation or manual refresh needed. Co-Authored-By: Claude Sonnet 5 --- .../com/itsaky/androidide/di/PluginModule.kt | 7 ++ .../androidide/ui/compose/ManagerScreen.kt | 8 +- .../ui/models/PluginManagerUiState.kt | 6 ++ .../viewmodels/PluginManagerViewModel.kt | 11 +++ .../viewmodels/PluginManagerViewModelTest.kt | 75 +++++++++++++++++++ .../androidide/utils/ConnectivityObserver.kt | 73 ++++++++++++++++++ 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodels/PluginManagerViewModelTest.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/ConnectivityObserver.kt diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index abc42ee177..cbfa0e60d8 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -5,6 +5,8 @@ import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.repositories.PluginRepositoryImpl import com.itsaky.androidide.repositories.TemplateCollectionRepository import com.itsaky.androidide.repositories.TemplateCollectionRepositoryImpl +import com.itsaky.androidide.utils.AndroidConnectivityObserver +import com.itsaky.androidide.utils.ConnectivityObserver import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel import com.itsaky.androidide.viewmodels.PluginManagerViewModel import org.koin.android.ext.koin.androidContext @@ -30,12 +32,17 @@ val pluginModule = TemplateCollectionRepositoryImpl() } + single { + AndroidConnectivityObserver(androidContext()) + } + // ViewModel viewModel { PluginManagerViewModel( pluginRepository = get(), contentResolver = androidContext().contentResolver, filesDir = IDEApplication.cachedFilesDir, + connectivityObserver = get(), ) } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt index bf5c7c58f3..3da6ae91a3 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -138,7 +138,8 @@ private class LongPressAwareClick( * Extensions Manager, and "add an extension" means the same thing whichever tab you happen to be * looking at. The picked file is routed by extension and the matching tab is brought forward, so * the result is visible where it landed. The discover-plugins action stays Plugins-only: it opens - * a plugin catalog, which has no meaning on the Templates tab. + * a plugin catalog, which has no meaning on the Templates tab. It's also hidden while offline + * (ADFA-5646), since it opens a URL with no offline fallback. * * The picker launcher lives here rather than in [PluginManagerContent] because `HorizontalPager` * disposes the off-screen page: a launcher owned by the Plugins page would not exist while the @@ -222,7 +223,10 @@ fun ManagerScreen( } }, actions = { - if (pagerState.currentPage == TAB_PLUGINS) { + // Hidden rather than disabled when offline (ADFA-5646): the action opens a URL + // with no offline fallback, and a hidden control makes that absence self-evident + // instead of inviting a tap that can only fail. + if (pagerState.currentPage == TAB_PLUGINS && pluginUiState.isOnline) { // Not an IconButton: it appends its own clickable() after this modifier, which // would compete with combinedClickable's detector for the same pointer events - // see rememberLongPressInteractionSource's doc. .size(48.dp) matches diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt index e7eae77bc8..e89e1c517a 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt @@ -13,6 +13,12 @@ data class PluginManagerUiState( val plugins: List = emptyList(), val isPluginManagerAvailable: Boolean = false, val isInstalling: Boolean = false, + /** + * Defaults `true` (optimistic) rather than `false`: the common case is a connected device, and + * defaulting offline would flash-hide the discover-plugins action on every cold start until + * [com.itsaky.androidide.utils.ConnectivityObserver] delivers its first (synchronous) value. + */ + val isOnline: Boolean = true, ) { val isEmpty: Boolean get() = plugins.isEmpty() && !isLoading diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index c4a27463f4..8b34c1691f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -14,6 +14,7 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation +import com.itsaky.androidide.utils.ConnectivityObserver import com.itsaky.androidide.utils.EditorDecorationBridge import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate @@ -42,6 +43,7 @@ class PluginManagerViewModel( private val pluginRepository: PluginRepository, private val contentResolver: ContentResolver, private val filesDir: File, + private val connectivityObserver: ConnectivityObserver, ) : ViewModel() { private companion object { private const val TAG = "PluginManagerViewModel" @@ -113,6 +115,15 @@ class PluginManagerViewModel( init { loadPlugins() + + // The discover-plugins action opens a URL and has no offline fallback (ADFA-5646) - drive + // its visibility from live connectivity rather than a one-time check, so it hides the + // moment the device goes offline instead of only failing the next time it's tapped. + viewModelScope.launch { + connectivityObserver.observe().collect { online -> + _uiState.update { it.copy(isOnline = online) } + } + } } /** diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/PluginManagerViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/PluginManagerViewModelTest.kt new file mode 100644 index 0000000000..e385ad80e3 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/PluginManagerViewModelTest.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.viewmodels + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.repositories.PluginRepository +import com.itsaky.androidide.utils.ConnectivityObserver +import com.itsaky.androidide.viewmodel.MainDispatcherRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.io.File + +/** A fake [ConnectivityObserver] whose emissions are driven by [state], for tests that need to simulate a change over time. */ +private class FakeConnectivityObserver( + initialValue: Boolean, +) : ConnectivityObserver { + val state = MutableStateFlow(initialValue) + + override fun observe() = state +} + +/** + * Covers only [PluginManagerViewModel.uiState]'s `isOnline` field (ADFA-5646) - the rest of this + * ViewModel has no test coverage yet, which is out of scope here. + */ +@RunWith(JUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class PluginManagerViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val pluginRepository = + mockk { + // Keeps init{}'s loadPlugins() a no-op past this check, so the test doesn't need to + // stub the rest of the load path - irrelevant to what's under test here. + every { isPluginManagerAvailable() } returns false + } + + private fun viewModel(connectivityObserver: ConnectivityObserver) = + PluginManagerViewModel( + pluginRepository = pluginRepository, + contentResolver = mockk(relaxed = true), + filesDir = File("/tmp"), + connectivityObserver = connectivityObserver, + ) + + @Test + fun uiState_isOnline_reflectsConnectivityObserverOnInit() = + runTest { + val viewModel = viewModel(FakeConnectivityObserver(initialValue = false)) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isOnline).isFalse() + } + + @Test + fun uiState_isOnline_updatesWhenConnectivityChanges() = + runTest { + val connectivityObserver = FakeConnectivityObserver(initialValue = true) + val viewModel = viewModel(connectivityObserver) + advanceUntilIdle() + assertThat(viewModel.uiState.value.isOnline).isTrue() + + connectivityObserver.state.value = false + advanceUntilIdle() + + assertThat(viewModel.uiState.value.isOnline).isFalse() + } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/ConnectivityObserver.kt b/common/src/main/java/com/itsaky/androidide/utils/ConnectivityObserver.kt new file mode 100644 index 0000000000..58405ea386 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/ConnectivityObserver.kt @@ -0,0 +1,73 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Live internet-connectivity state, for UI that must react to connectivity changes rather than + * check it once - e.g. hiding an action that requires the network before the user taps it and + * hits a dead end, instead of only refusing the tap after the fact. + */ +interface ConnectivityObserver { + /** Emits the current state immediately upon collection, then again on every change. */ + fun observe(): Flow +} + +/** + * [ConnectivityManager]-backed [ConnectivityObserver]. [Context.isNetworkConnected] answers "is + * the active network capable of internet right now"; this wraps that same check in a + * [NetworkCallback] so callers get updates instead of having to poll. + */ +class AndroidConnectivityObserver( + private val context: Context, +) : ConnectivityObserver { + override fun observe(): Flow = + callbackFlow { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + if (connectivityManager == null) { + trySend(false) + close() + return@callbackFlow + } + + // Re-derives from the active network on every callback rather than trusting + // onAvailable/onLost's own network in isolation: onLost fires for the network that + // was lost, not the device's overall state, so switching Wi-Fi -> cellular must not + // be reported as "offline" just because the Wi-Fi network specifically went away. + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(context.isNetworkConnected()) + } + + override fun onLost(network: Network) { + trySend(context.isNetworkConnected()) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + trySend(context.isNetworkConnected()) + } + } + + trySend(context.isNetworkConnected()) + + val request = + NetworkRequest + .Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +}