diff --git a/app/build.gradle b/app/build.gradle index 92932253bf68866badac74bc5c87ce933b614a98..c3595339e226293341bbc20b59c7429b0acc485b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,13 +116,17 @@ android { versionCode = versionMajor * 1000000 + versionMinor * 1000 + versionPatch versionName = "${versionMajor}.${versionMinor}.${versionPatch}" + def fdroidHost = "f-droid.org" + buildConfigField "String", "BUILD_ID", "\"${getGitHash() + "." + getDate()}\"" buildConfigField("String", "SENTRY_DSN", "\"${getSentryDsn()}\"") buildConfigField "String", "USER_AGENT", "\"${retrieveKey("user_agent", "Dalvik/2.1.0 (Linux; U; Android %s)")}\"" + buildConfigField "String", "FDROID_HOST", "\"${fdroidHost}\"" + buildConfigField "String", "FDROID_REPO_BASE_URL", "\"https://${fdroidHost}/repo/\"" def parentalControlPkgName = "foundation.e.parentalcontrol" - manifestPlaceholders = [parentalControlPkgName: parentalControlPkgName] + manifestPlaceholders = [parentalControlPkgName: parentalControlPkgName, fdroidHost: fdroidHost] buildConfigField "String", "PACKAGE_NAME_PARENTAL_CONTROL", "\"${parentalControlPkgName}\"" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50db1448da807e0ec8cb2d301927082e4ff5fc1d..fa07e50d9a37623366cdbcc002171524b56589a1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -121,8 +121,11 @@ - - + + + + + diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt index d85ba578f995aee8b28142acf4567f8553df4cb2..d10703978a9e75ddda6459dbf64826650d899cd4 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt @@ -23,6 +23,7 @@ import androidx.core.net.toUri import com.aurora.gplayapi.Constants.Restriction import com.aurora.gplayapi.data.models.ContentRating import com.google.gson.annotations.SerializedName +import foundation.e.apps.BuildConfig import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.Source @@ -142,7 +143,7 @@ val Application.shareUri: Uri private fun buildFDroidUri(packageName: String): Uri { return Uri.Builder() .scheme("https") - .authority("f-droid.org") + .authority(BuildConfig.FDROID_HOST) .appendPath("packages") .appendPath(packageName) .build() diff --git a/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt b/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt index b25d776b6901fb60a0a68a3167bef53d9deb165b..f4f579daf4bf3736ae5faa6cbbc4f6101a8f8f7c 100644 --- a/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt +++ b/app/src/main/java/foundation/e/apps/data/di/network/RetrofitApiModule.kt @@ -24,6 +24,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import foundation.e.apps.BuildConfig import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.di.network.NetworkModule.getYamlFactory import foundation.e.apps.data.ecloud.EcloudApiInterface @@ -103,7 +104,7 @@ class RetrofitApiModule { @Provides fun provideFDroidMonitorApi(okHttpClient: OkHttpClient, moshi: Moshi): FDroidMonitorApi { return Retrofit.Builder() - .baseUrl(FDroidMonitorApi.BASE_URL) + .baseUrl(BuildConfig.FDROID_REPO_BASE_URL) .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() diff --git a/app/src/main/java/foundation/e/apps/data/parentalcontrol/fdroid/FDroidMonitorApi.kt b/app/src/main/java/foundation/e/apps/data/parentalcontrol/fdroid/FDroidMonitorApi.kt index dcc01b76444a297709bbafc458fee197f2071953..3496bc718823571277db296b5e6d5433c6da3a42 100644 --- a/app/src/main/java/foundation/e/apps/data/parentalcontrol/fdroid/FDroidMonitorApi.kt +++ b/app/src/main/java/foundation/e/apps/data/parentalcontrol/fdroid/FDroidMonitorApi.kt @@ -23,10 +23,6 @@ import retrofit2.http.GET interface FDroidMonitorApi { - companion object { - const val BASE_URL = "https://f-droid.org/repo/" - } - @GET("status/update.json") suspend fun getMonitorData(): Response } diff --git a/app/src/main/java/foundation/e/apps/ui/FDroidDeepLinkParser.kt b/app/src/main/java/foundation/e/apps/ui/FDroidDeepLinkParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..c6f2855361e2f8ac5f89f86b5549cd6933323ed7 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/FDroidDeepLinkParser.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.ui + +import android.net.Uri +import foundation.e.apps.BuildConfig + +internal object FDroidDeepLinkParser { + private const val PACKAGES_PATH_SEGMENT = "packages" + private const val DIRECT_PACKAGE_SEGMENT_COUNT = 2 + private const val LOCALIZED_PACKAGE_SEGMENT_COUNT = 3 + private const val LOCALIZED_PACKAGES_SEGMENT_INDEX = 1 + + fun getPackageName(uri: Uri?): String? { + if (uri == null || !uri.host.equals(BuildConfig.FDROID_HOST, ignoreCase = true)) { + return null + } + + val pathSegments = uri.pathSegments + return when { + pathSegments.isDirectPackagePath() || pathSegments.isLocalizedPackagePath() -> pathSegments.last() + else -> null + }?.takeIf { it.isNotBlank() } + } + + private fun List.isDirectPackagePath(): Boolean { + return size == DIRECT_PACKAGE_SEGMENT_COUNT && first() == PACKAGES_PATH_SEGMENT + } + + private fun List.isLocalizedPackagePath(): Boolean { + return size == LOCALIZED_PACKAGE_SEGMENT_COUNT && get(LOCALIZED_PACKAGES_SEGMENT_INDEX) == PACKAGES_PATH_SEGMENT + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt index 92a05c5e15ee6fabf5425475e4553cd6917b2e75..e972acd6d60a374a102360d914d562f90749deb2 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt @@ -47,6 +47,7 @@ import foundation.e.apps.BuildConfig import foundation.e.apps.R import foundation.e.apps.contract.ParentalControlContract.COLUMN_LOGIN_TYPE import foundation.e.apps.data.Constants +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.event.EventBus import foundation.e.apps.data.install.models.AppInstall @@ -55,6 +56,7 @@ import foundation.e.apps.data.login.core.StoreType import foundation.e.apps.data.system.ParentalControlAuthenticator import foundation.e.apps.databinding.ActivityMainBinding import foundation.e.apps.domain.model.User +import foundation.e.apps.ui.application.ApplicationFragmentArgs import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.error.AppUnavailableDialogDirections import foundation.e.apps.ui.purchase.AppPurchaseFragmentDirections @@ -123,6 +125,10 @@ class MainActivity : AppCompatActivity() { val (bottomNavigationView, navController) = setupBootomNav() + if (savedInstanceState == null) { + handleFdroidDeepLink(navController, intent) + } + setupViewModels() setupNavigations(navController) @@ -162,8 +168,33 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) checkGPlayLoginRequest(intent) - findNavController(R.id.fragment).handleDeepLink(intent) + val navController = findNavController(R.id.fragment) + if (handleFdroidDeepLink(navController, intent)) { + return + } + navController.handleDeepLink(intent) + } + + private fun handleFdroidDeepLink(navController: NavController, intent: Intent?): Boolean { + val packageName = FDroidDeepLinkParser.getPackageName(intent?.data) ?: return false + + val args = ApplicationFragmentArgs( + id = "", + packageName = packageName, + source = Source.OPEN_SOURCE, + category = "", + isGplayReplaced = false, + isPurchased = false, + ).toBundle() + + val navOptions = NavOptions.Builder() + .setLaunchSingleTop(true) + .build() + navController.navigate(R.id.applicationFragment, args, navOptions) + + return true } private fun checkGPlayLoginRequest(intent: Intent?) { diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt index 047850460ff04db0deb34c1635e6ff36d4b3598f..f5f65d2aac4b1db08f452dd06833edfe68e93601 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt @@ -50,6 +50,7 @@ import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import com.google.android.material.textview.MaterialTextView import dagger.hilt.android.AndroidEntryPoint +import foundation.e.apps.BuildConfig import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.shareUri @@ -102,7 +103,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5509 */ private val isFdroidDeepLink: Boolean by lazy { - activity?.intent?.data?.host?.equals("f-droid.org") ?: false + activity?.intent?.data?.host?.equals(BuildConfig.FDROID_HOST) ?: false } private var isDetailsLoaded = false diff --git a/app/src/main/res/navigation/navigation_resource.xml b/app/src/main/res/navigation/navigation_resource.xml index 6d074f39f57fe31b69c663b0cefcdf2ba41aaa69..60d9c631e1f4b3a1298c63e7368b97ea054292b7 100644 --- a/app/src/main/res/navigation/navigation_resource.xml +++ b/app/src/main/res/navigation/navigation_resource.xml @@ -129,19 +129,10 @@ - - - + diff --git a/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt b/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt index c47d52642a26d9c9e89db52ee33a70e8bce21e50..d9591298b265ffe432f46abdb6140aea123b1abd 100644 --- a/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt +++ b/app/src/test/java/foundation/e/apps/data/application/data/ApplicationTest.kt @@ -1,6 +1,7 @@ package foundation.e.apps.data.application.data import com.google.common.truth.Truth.assertThat +import foundation.e.apps.BuildConfig import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Type @@ -39,7 +40,7 @@ class ApplicationTest { val uri = app.shareUri - assertThat(uri.toString()).isEqualTo("https://f-droid.org/packages/org.fdroid.app") + assertThat(uri.toString()).isEqualTo("https://${BuildConfig.FDROID_HOST}/packages/org.fdroid.app") } @Test diff --git a/app/src/test/java/foundation/e/apps/ui/FDroidDeepLinkParserTest.kt b/app/src/test/java/foundation/e/apps/ui/FDroidDeepLinkParserTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b706d8611b6099e66e13495daf9b19359c300e9b --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/FDroidDeepLinkParserTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.ui + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.BuildConfig +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FDroidDeepLinkParserTest { + + @Test + fun getPackageName_returnsPackageForNonLocalizedUrl() { + val packageName = FDroidDeepLinkParser.getPackageName( + Uri.parse("https://${BuildConfig.FDROID_HOST}/packages/org.fdroid.fdroid") + ) + + assertThat(packageName).isEqualTo("org.fdroid.fdroid") + } + + @Test + fun getPackageName_returnsPackageForLocalizedUrlWithTrailingSlash() { + val packageName = FDroidDeepLinkParser.getPackageName( + Uri.parse("https://${BuildConfig.FDROID_HOST}/en/packages/org.fdroid.fdroid/") + ) + + assertThat(packageName).isEqualTo("org.fdroid.fdroid") + } + + @Test + fun getPackageName_returnsNullWhenPackageNameIsMissing() { + val packageName = FDroidDeepLinkParser.getPackageName( + Uri.parse("https://${BuildConfig.FDROID_HOST}/en/packages/") + ) + + assertThat(packageName).isNull() + } + + @Test + fun getPackageName_returnsNullWhenPathHasExtraSegments() { + val packageName = FDroidDeepLinkParser.getPackageName( + Uri.parse("https://${BuildConfig.FDROID_HOST}/en/packages/org.fdroid.fdroid/stats") + ) + + assertThat(packageName).isNull() + } + + @Test + fun getPackageName_returnsNullForNonFdroidHost() { + val packageName = FDroidDeepLinkParser.getPackageName( + Uri.parse("https://example.org/en/packages/org.fdroid.fdroid") + ) + + assertThat(packageName).isNull() + } +}