diff options
| author | Albin <albin@mullvad.net> | 2026-04-22 12:50:15 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2026-04-22 12:50:15 +0200 |
| commit | 05e77fabf590e0db31f5992817b942a8ef8485b4 (patch) | |
| tree | 8e487acfe1b1d309ec587385b05c27e0a41fc19c | |
| parent | dcbbbf0f036d297074aef10faed7d344e585a058 (diff) | |
| download | mullvadvpn-05e77fabf590e0db31f5992817b942a8ef8485b4.tar.xz mullvadvpn-05e77fabf590e0db31f5992817b942a8ef8485b4.zip | |
Add app listing resolution support
Adds support for determining the install source of
our app in order to provide users with relevant
download links.
10 files changed, 180 insertions, 45 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index c86532f65f..686e322d74 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -20,6 +20,10 @@ import net.mullvad.mullvadvpn.feature.apiaccess.impl.screen.save.SaveApiAccessMe import net.mullvad.mullvadvpn.feature.appicon.impl.AppIconViewModel import net.mullvad.mullvadvpn.feature.appinfo.impl.AppInfoViewModel import net.mullvad.mullvadvpn.feature.appinfo.impl.changelog.ChangelogViewModel +import net.mullvad.mullvadvpn.feature.applisting.api.ResolveAppListingUseCase +import net.mullvad.mullvadvpn.feature.applisting.impl.AndroidInstallSourceProvider +import net.mullvad.mullvadvpn.feature.applisting.impl.InstallSourceProvider +import net.mullvad.mullvadvpn.feature.applisting.impl.ResolveAppListingUseCaseImpl import net.mullvad.mullvadvpn.feature.autoconnect.impl.AutoConnectAndLockdownModeViewModel import net.mullvad.mullvadvpn.feature.customlist.impl.screen.create.CreateCustomListDialogViewModel import net.mullvad.mullvadvpn.feature.customlist.impl.screen.delete.DeleteCustomListConfirmationViewModel @@ -129,7 +133,15 @@ val uiModule = module { ComponentName(androidContext(), AutoStartVpnBootCompletedReceiver::class.java) } - single { PackageName(androidContext().packageName) } + single<InstallSourceProvider> { AndroidInstallSourceProvider(androidContext()) } + single<ResolveAppListingUseCase> { + ResolveAppListingUseCaseImpl( + resources = androidContext().resources, + packageName = PackageName(androidContext().packageName), + isPlayBuild = IS_PLAY_BUILD, + installSourceProvider = get(), + ) + } single { ApplicationsProvider(get(), get()) } scope<MainActivity> { scoped { ServiceConnectionManager(androidContext()) } } single { InetAddressValidator.getInstance() } @@ -263,10 +275,8 @@ val uiModule = module { viewModel { AppInfoViewModel( appVersionInfoRepository = get(), - resources = get(), isPlayBuild = IS_PLAY_BUILD, - isFdroidBuild = false, - self = get(), + resolveAppListing = get(), ) } viewModel { @@ -283,10 +293,8 @@ val uiModule = module { connectionProxy = get(), lastKnownLocationUseCase = get(), systemVpnSettingsUseCase = get(), - resources = get(), isPlayBuild = IS_PLAY_BUILD, - isFdroidBuild = false, - self = get(), + resolveAppListing = get(), ) } viewModel { params -> DeviceListViewModel(accountNumber = params.get(), get()) } diff --git a/android/lib/feature/appinfo/impl/src/main/java/net/mullvad/mullvadvpn/feature/appinfo/impl/AppInfoViewModel.kt b/android/lib/feature/appinfo/impl/src/main/java/net/mullvad/mullvadvpn/feature/appinfo/impl/AppInfoViewModel.kt index 7c39a1d9f8..86c01717d0 100644 --- a/android/lib/feature/appinfo/impl/src/main/java/net/mullvad/mullvadvpn/feature/appinfo/impl/AppInfoViewModel.kt +++ b/android/lib/feature/appinfo/impl/src/main/java/net/mullvad/mullvadvpn/feature/appinfo/impl/AppInfoViewModel.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.feature.appinfo.impl -import android.content.res.Resources import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -12,18 +11,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.feature.applisting.api.ResolveAppListingUseCase import net.mullvad.mullvadvpn.lib.common.Lc import net.mullvad.mullvadvpn.lib.common.constant.VIEW_MODEL_STOP_TIMEOUT -import net.mullvad.mullvadvpn.lib.model.PackageName import net.mullvad.mullvadvpn.lib.repository.AppVersionInfoRepository -import net.mullvad.mullvadvpn.lib.ui.resource.R class AppInfoViewModel( appVersionInfoRepository: AppVersionInfoRepository, - private val resources: Resources, private val isPlayBuild: Boolean, - private val isFdroidBuild: Boolean, - private val self: PackageName, + private val resolveAppListing: ResolveAppListingUseCase, ) : ViewModel() { private val _uiSideEffect = Channel<AppInfoSideEffect>() @@ -39,18 +35,12 @@ class AppInfoViewModel( ) fun openAppListing() = viewModelScope.launch { + val target = resolveAppListing() val sideEffect = - if (isPlayBuild || isFdroidBuild) { - AppInfoSideEffect.OpenUri( - uri = resources.getString(R.string.market_uri, self.value).toUri(), - errorMessage = resources.getString(R.string.uri_market_app_not_found), - ) - } else { - AppInfoSideEffect.OpenUri( - uri = resources.getString(R.string.download_url).toUri(), - errorMessage = resources.getString(R.string.uri_browser_app_not_found), - ) - } + AppInfoSideEffect.OpenUri( + uri = target.listingUri.toUri(), + errorMessage = target.errorMessage, + ) _uiSideEffect.send(sideEffect) } } diff --git a/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/AppListingTarget.kt b/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/AppListingTarget.kt new file mode 100644 index 0000000000..2275539ebf --- /dev/null +++ b/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/AppListingTarget.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.feature.applisting.api + +data class AppListingTarget(val listingUri: String, val errorMessage: String) diff --git a/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/ResolveAppListingUseCase.kt b/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/ResolveAppListingUseCase.kt new file mode 100644 index 0000000000..0766b4f5c7 --- /dev/null +++ b/android/lib/feature/applisting/api/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/api/ResolveAppListingUseCase.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.feature.applisting.api + +fun interface ResolveAppListingUseCase { + operator fun invoke(): AppListingTarget +} diff --git a/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/AndroidInstallSourceProvider.kt b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/AndroidInstallSourceProvider.kt new file mode 100644 index 0000000000..0e48140df9 --- /dev/null +++ b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/AndroidInstallSourceProvider.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.feature.applisting.impl + +import android.content.Context +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build + +class AndroidInstallSourceProvider(private val context: Context) : InstallSourceProvider { + override fun isInstalledFromStore(): Boolean { + val packageName = context.packageName + val packageManager = context.packageManager + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> + try { + packageManager.getInstallSourceInfo(packageName).packageSource == + PackageInstaller.PACKAGE_SOURCE_STORE + } catch (_: PackageManager.NameNotFoundException) { + false + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + try { + packageManager.getInstallSourceInfo(packageName).installingPackageName != null + } catch (_: PackageManager.NameNotFoundException) { + false + } + else -> + @Suppress("DEPRECATION") + (packageManager.getInstallerPackageName(packageName) != null) + } + } +} diff --git a/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/InstallSourceProvider.kt b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/InstallSourceProvider.kt new file mode 100644 index 0000000000..c1b8910c74 --- /dev/null +++ b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/InstallSourceProvider.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.feature.applisting.impl + +fun interface InstallSourceProvider { + fun isInstalledFromStore(): Boolean +} diff --git a/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImpl.kt b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImpl.kt new file mode 100644 index 0000000000..0fa6058407 --- /dev/null +++ b/android/lib/feature/applisting/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImpl.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.feature.applisting.impl + +import android.content.res.Resources +import net.mullvad.mullvadvpn.feature.applisting.api.AppListingTarget +import net.mullvad.mullvadvpn.feature.applisting.api.ResolveAppListingUseCase +import net.mullvad.mullvadvpn.lib.model.PackageName +import net.mullvad.mullvadvpn.lib.ui.resource.R + +class ResolveAppListingUseCaseImpl( + private val resources: Resources, + private val packageName: PackageName, + private val isPlayBuild: Boolean, + private val installSourceProvider: InstallSourceProvider, +) : ResolveAppListingUseCase { + override fun invoke(): AppListingTarget = + if (isPlayBuild || installSourceProvider.isInstalledFromStore()) { + AppListingTarget( + listingUri = resources.getString(R.string.market_uri, packageName.value), + errorMessage = resources.getString(R.string.uri_market_app_not_found), + ) + } else { + AppListingTarget( + listingUri = resources.getString(R.string.download_url), + errorMessage = resources.getString(R.string.uri_browser_app_not_found), + ) + } +} diff --git a/android/lib/feature/applisting/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImplTest.kt b/android/lib/feature/applisting/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImplTest.kt new file mode 100644 index 0000000000..495b30db51 --- /dev/null +++ b/android/lib/feature/applisting/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/applisting/impl/ResolveAppListingUseCaseImplTest.kt @@ -0,0 +1,79 @@ +package net.mullvad.mullvadvpn.feature.applisting.impl + +import android.content.res.Resources +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertEquals +import net.mullvad.mullvadvpn.lib.model.PackageName +import net.mullvad.mullvadvpn.lib.ui.resource.R +import org.junit.jupiter.api.Test + +class ResolveAppListingUseCaseImplTest { + + private val mockResources: Resources = mockk() + + @Test + fun `when play build should return market uri`() { + // Arrange + val useCase = createUseCase(isPlayBuild = true, isStoreInstall = false) + + // Act + val result = useCase() + + // Assert + assertEquals(MARKET_URI, result.listingUri) + assertEquals(MARKET_ERROR, result.errorMessage) + } + + @Test + fun `when store install should return market uri`() { + // Arrange + val useCase = createUseCase(isPlayBuild = false, isStoreInstall = true) + + // Act + val result = useCase() + + // Assert + assertEquals(MARKET_URI, result.listingUri) + assertEquals(MARKET_ERROR, result.errorMessage) + } + + @Test + fun `when sideloaded build should return download url`() { + // Arrange + val useCase = createUseCase(isPlayBuild = false, isStoreInstall = false) + + // Act + val result = useCase() + + // Assert + assertEquals(DOWNLOAD_URL, result.listingUri) + assertEquals(BROWSER_ERROR, result.errorMessage) + } + + private fun createUseCase( + isPlayBuild: Boolean, + isStoreInstall: Boolean, + ): ResolveAppListingUseCaseImpl { + every { mockResources.getString(R.string.market_uri, PACKAGE_NAME.value) } returns + MARKET_URI + every { mockResources.getString(R.string.download_url) } returns DOWNLOAD_URL + every { mockResources.getString(R.string.uri_market_app_not_found) } returns MARKET_ERROR + every { mockResources.getString(R.string.uri_browser_app_not_found) } returns BROWSER_ERROR + + return ResolveAppListingUseCaseImpl( + resources = mockResources, + packageName = PACKAGE_NAME, + isPlayBuild = isPlayBuild, + installSourceProvider = InstallSourceProvider { isStoreInstall }, + ) + } + + companion object { + private val PACKAGE_NAME = PackageName("net.mullvad.mullvadvpn") + private const val MARKET_URI = "market://details?id=net.mullvad.mullvadvpn" + private const val DOWNLOAD_URL = "https://mullvad.net/download/vpn/android" + private const val MARKET_ERROR = "No Android app store installed, could not open link" + private const val BROWSER_ERROR = "No browser app installed, could not open link" + } +} diff --git a/android/lib/feature/home/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModel.kt b/android/lib/feature/home/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModel.kt index eae192ccad..18efcb3637 100644 --- a/android/lib/feature/home/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModel.kt +++ b/android/lib/feature/home/impl/src/main/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModel.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.feature.home.impl.connect -import android.content.res.Resources import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.feature.applisting.api.ResolveAppListingUseCase import net.mullvad.mullvadvpn.feature.home.impl.connect.notificationbanner.InAppNotificationController import net.mullvad.mullvadvpn.lib.common.constant.VIEW_MODEL_STOP_TIMEOUT import net.mullvad.mullvadvpn.lib.common.util.combine @@ -27,7 +27,6 @@ import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.ConnectError import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.DisconnectReason -import net.mullvad.mullvadvpn.lib.model.PackageName import net.mullvad.mullvadvpn.lib.model.PrepareError import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken @@ -39,7 +38,6 @@ import net.mullvad.mullvadvpn.lib.repository.DeviceRepository import net.mullvad.mullvadvpn.lib.repository.NewDeviceRepository import net.mullvad.mullvadvpn.lib.repository.PaymentLogic import net.mullvad.mullvadvpn.lib.repository.UserPreferencesRepository -import net.mullvad.mullvadvpn.lib.ui.resource.R import net.mullvad.mullvadvpn.lib.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.lib.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.lib.usecase.SelectedLocationTitleUseCase @@ -59,10 +57,8 @@ class ConnectViewModel( private val connectionProxy: ConnectionProxy, lastKnownLocationUseCase: LastKnownLocationUseCase, private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase, - private val resources: Resources, private val isPlayBuild: Boolean, - private val isFdroidBuild: Boolean, - private val self: PackageName, + private val resolveAppListing: ResolveAppListingUseCase, ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() @@ -198,18 +194,12 @@ class ConnectViewModel( } fun openAppListing() = viewModelScope.launch { + val target = resolveAppListing() val sideEffect = - if (isPlayBuild || isFdroidBuild) { - UiSideEffect.OpenUri( - uri = resources.getString(R.string.market_uri, self.value).toUri(), - errorMessage = resources.getString(R.string.uri_market_app_not_found), - ) - } else { - UiSideEffect.OpenUri( - uri = resources.getString(R.string.download_url).toUri(), - errorMessage = resources.getString(R.string.uri_browser_app_not_found), - ) - } + UiSideEffect.OpenUri( + uri = target.listingUri.toUri(), + errorMessage = target.errorMessage, + ) _uiSideEffect.send(sideEffect) } diff --git a/android/lib/feature/home/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModelTest.kt b/android/lib/feature/home/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModelTest.kt index 141d2e6695..74291039de 100644 --- a/android/lib/feature/home/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModelTest.kt +++ b/android/lib/feature/home/impl/src/test/kotlin/net/mullvad/mullvadvpn/feature/home/impl/connect/ConnectViewModelTest.kt @@ -26,7 +26,6 @@ import net.mullvad.mullvadvpn.lib.model.DisconnectReason import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.GeoIpLocation import net.mullvad.mullvadvpn.lib.model.InAppNotification -import net.mullvad.mullvadvpn.lib.model.PackageName import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken @@ -124,10 +123,8 @@ class ConnectViewModelTest { connectionProxy = mockConnectionProxy, lastKnownLocationUseCase = mockLastKnownLocationUseCase, systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase, - resources = mockk(), isPlayBuild = false, - isFdroidBuild = false, - self = PackageName("net.mullvad.mullvadvpn"), + resolveAppListing = mockk(), ) } |
