Skip to content
Open
9 changes: 9 additions & 0 deletions app/src/main/java/com/itsaky/androidide/di/PluginModule.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.itsaky.androidide.di

import com.itsaky.androidide.app.IDEApplication
import com.itsaky.androidide.preferences.internal.BuildPreferences
import com.itsaky.androidide.repositories.DownloadFilesRepository
import com.itsaky.androidide.repositories.DownloadFilesRepositoryImpl
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.utils.Environment
import com.itsaky.androidide.viewmodels.DownloadPickerViewModel
import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
Expand Down Expand Up @@ -34,6 +37,10 @@ val pluginModule =
TemplateCollectionRepositoryImpl()
}

single<ConnectivityObserver> {
AndroidConnectivityObserver(androidContext())
}

single<DownloadFilesRepository> {
DownloadFilesRepositoryImpl(downloadDir = Environment.DOWNLOAD_DIR)
}
Expand All @@ -44,6 +51,8 @@ val pluginModule =
pluginRepository = get(),
contentResolver = androidContext().contentResolver,
filesDir = IDEApplication.cachedFilesDir,
connectivityObserver = get(),
isOfflineModeEnabled = { BuildPreferences.isOfflineEnabled },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ internal class LongPressAwareClick(
* folders button falls back to the system picker, which accepts either archive type from any
* folder. Either way 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.
* Plugins-only: it opens a plugin catalog, which has no meaning on the Templates tab. It's also
* hidden while offline or in Gradle offline mode (ADFA-5646), since it opens a URL with no
* offline fallback.
*
* The system picker's 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
Expand Down Expand Up @@ -271,7 +273,10 @@ fun ManagerScreen(
}
},
actions = {
if (pagerState.currentPage == TAB_PLUGINS) {
// Hidden rather than disabled when offline or in offline mode (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.showDiscoverPlugins) {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ data class PluginManagerUiState(
val plugins: List<PluginInfo> = 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,
/** Gradle's offline mode is on: the user chose to work offline. */
val isOfflineModeEnabled: Boolean = false,
) {
/** The discover-plugins action opens a website, so it needs a connection and online mode. */
val showDiscoverPlugins: Boolean
get() = isOnline && !isOfflineModeEnabled

val isEmpty: Boolean
get() = plugins.isEmpty() && !isLoading

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,9 @@ class PluginManagerViewModel(
private val pluginRepository: PluginRepository,
private val contentResolver: ContentResolver,
private val filesDir: File,
private val connectivityObserver: ConnectivityObserver,
// Gradle's offline mode (Build & Run preferences). A function so tests need no preferences.
private val isOfflineModeEnabled: () -> Boolean,
) : ViewModel() {
private companion object {
private const val TAG = "PluginManagerViewModel"
Expand Down Expand Up @@ -113,6 +117,17 @@ 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.
// The preference is read with each value. It is set in Preferences, which is reached only by
// leaving this screen, so a change always meets a new ViewModel.
viewModelScope.launch {
connectivityObserver.observe().collect { online ->
_uiState.update { it.copy(isOnline = online, isOfflineModeEnabled = isOfflineModeEnabled()) }
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.itsaky.androidide.repositories.PluginRepository
import com.itsaky.androidide.ui.models.PluginInstallSource
import com.itsaky.androidide.ui.models.PluginManagerUiEffect
import com.itsaky.androidide.ui.models.PluginManagerUiEvent
import com.itsaky.androidide.utils.ConnectivityObserver
import com.itsaky.androidide.viewmodel.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.every
Expand All @@ -17,6 +18,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
Expand Down Expand Up @@ -58,6 +60,12 @@ class PluginManagerViewModelSourceDeleteTest {
pluginRepository = pluginRepository,
contentResolver = context.contentResolver,
filesDir = tempFolder.newFolder("files"),
// Connectivity (ADFA-5646) plays no part in source deletion: always online, offline mode off.
connectivityObserver =
object : ConnectivityObserver {
override fun observe() = flowOf(true)
},
isOfflineModeEnabled = { false },
)

private fun downloadedPlugin(): File = File(tempFolder.newFolder("Download"), "my-plugin.cgp").apply { writeText("cgp") }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 connectivity and offline-mode fields (ADFA-5646).
* Install-source deletion is covered by [PluginManagerViewModelSourceDeleteTest].
*/
@RunWith(JUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class PluginManagerViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()

private val pluginRepository =
mockk<PluginRepository> {
// 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,
offlineMode: Boolean = false,
) = PluginManagerViewModel(
pluginRepository = pluginRepository,
contentResolver = mockk(relaxed = true),
filesDir = File("/tmp"),
connectivityObserver = connectivityObserver,
isOfflineModeEnabled = { offlineMode },
)

@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()
}

@Test
fun showDiscoverPlugins_whenOnlineAndNotInOfflineMode() =
runTest {
val viewModel = viewModel(FakeConnectivityObserver(initialValue = true), offlineMode = false)
advanceUntilIdle()

assertThat(viewModel.uiState.value.showDiscoverPlugins).isTrue()
}

@Test
fun showDiscoverPlugins_hiddenInOfflineModeEvenWhenOnline() =
runTest {
val viewModel = viewModel(FakeConnectivityObserver(initialValue = true), offlineMode = true)
advanceUntilIdle()

assertThat(viewModel.uiState.value.showDiscoverPlugins).isFalse()
}

@Test
fun showDiscoverPlugins_hiddenWhenOffline() =
runTest {
val viewModel = viewModel(FakeConnectivityObserver(initialValue = false), offlineMode = false)
advanceUntilIdle()

assertThat(viewModel.uiState.value.showDiscoverPlugins).isFalse()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.itsaky.androidide.utils

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
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<Boolean>
}

/**
* [ConnectivityManager]-backed [ConnectivityObserver]. Online means the default network - the one
* the app's traffic uses, a VPN included - has validated internet: Android has confirmed it works,
* so a captive portal, a Wi-Fi with no uplink, or a broken VPN counts as offline.
*
* State comes from the default-network callback's own data, via [DefaultNetworkState], never from
* re-querying [ConnectivityManager] inside a callback: that can race callback delivery, and in
* onLost `activeNetwork` may still name the network just lost. With no network left, no later
* callback would correct the stale value.
*/
class AndroidConnectivityObserver(
private val context: Context,
) : ConnectivityObserver {
override fun observe(): Flow<Boolean> =
callbackFlow {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (connectivityManager == null) {
trySend(false)
close()
return@callbackFlow
}

val state = DefaultNetworkState<Network> { online -> trySend(online) }
val callback =
object : ConnectivityManager.NetworkCallback() {
// No onAvailable: from API 26 every onAvailable is followed by
// onCapabilitiesChanged, which carries the capabilities this needs.
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
state.onCapabilities(network, networkCapabilities.hasValidatedInternet())
}

override fun onLost(network: Network) {
state.onLost(network)
}
}

// The default network, not a NetworkRequest: a request excludes VPNs, while the initial
// sample's activeNetwork does not, so the two could disagree about which network counts.
// Register before sampling: a change after the sample then still arrives as a callback.
connectivityManager.registerDefaultNetworkCallback(callback)
state.onInitialSample(connectivityManager.hasValidatedInternet())

awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}

/**
* Whether the default network has validated internet, from default-network callback data in
* callback order. Calls [send] only when that answer changes.
*
* A loss counts only for the current default network: when the default switches (Wi-Fi to
* cellular, say), the new one is reported and a later onLost for the old one is not a disconnect.
*
* Registration reports the current default network but nothing when there is none, so the first
* value comes from [onInitialSample] - unless a callback has already answered, which is newer.
*
* Thread-safe: callbacks arrive on ConnectivityManager's thread, the sample on the collector's.
* [N] is the network handle; generic so the logic is testable without Android.
*/
internal class DefaultNetworkState<N>(
private val send: (online: Boolean) -> Unit,
) {
private var defaultNetwork: N? = null
private var lastSent: Boolean? = null
private var answeredByCallback = false

@Synchronized
fun onCapabilities(
network: N,
hasValidatedInternet: Boolean,
) {
defaultNetwork = network
answeredByCallback = true
sendIfChanged(hasValidatedInternet)
}

@Synchronized
fun onLost(network: N) {
if (network != defaultNetwork) return
defaultNetwork = null
answeredByCallback = true
sendIfChanged(false)
}

@Synchronized
fun onInitialSample(online: Boolean) {
if (!answeredByCallback) sendIfChanged(online)
}

private fun sendIfChanged(online: Boolean) {
if (online == lastSent) return
lastSent = online
send(online)
}
}

private fun NetworkCapabilities.hasValidatedInternet(): Boolean =
hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

private fun ConnectivityManager.hasValidatedInternet(): Boolean {
val network = activeNetwork ?: return false
return getNetworkCapabilities(network)?.hasValidatedInternet() == true
}
Loading
Loading