Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 47cbb743 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

fix: handle only valid F-Droid app links

Prevent App Lounge from claiming F-Droid URLs it cannot resolve, such as
category or locale listing pages. The original issue reported that App Lounge couldn't handle link without a trailing slash.

Keep support for real package detail links
so valid F-Droid app pages still open in App Lounge.
parent 4d7bbae5
Loading
Loading
Loading
Loading
+4 −1
Original line number Diff line number Diff line
@@ -122,7 +122,10 @@
                <data android:scheme="https" />
                <data android:scheme="http" />
                <data android:host="f-droid.org" />
                <data android:pathPattern=".*/packages/.*\..*" />
                <data android:pathAdvancedPattern="/packages/[^/]+" />
                <data android:pathAdvancedPattern="/packages/[^/]+/" />
                <data android:pathAdvancedPattern="/[^/]+/packages/[^/]+" />
                <data android:pathAdvancedPattern="/[^/]+/packages/[^/]+/" />
            </intent-filter>
            <!-- Google Play short links -->
            <intent-filter android:autoVerify="true">
+48 −0
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.ui

import android.net.Uri

internal object FDroidDeepLinkParser {
    private const val FDROID_HOST = "f-droid.org"
    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(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<String>.isDirectPackagePath(): Boolean {
        return size == DIRECT_PACKAGE_SEGMENT_COUNT && first() == PACKAGES_PATH_SEGMENT
    }

    private fun List<String>.isLocalizedPackagePath(): Boolean {
        return size == LOCALIZED_PACKAGE_SEGMENT_COUNT && get(LOCALIZED_PACKAGES_SEGMENT_INDEX) == PACKAGES_PATH_SEGMENT
    }
}
+32 −1
Original line number Diff line number Diff line
@@ -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?) {
+1 −10
Original line number Diff line number Diff line
@@ -129,19 +129,10 @@
        <deepLink
            app:action="android.intent.action.VIEW"
            app:uri="market://details?id={packageName}" />
        <!--
        Add support for f-droid packages
        Issue: https://gitlab.e.foundation/e/backlog/-/issues/5509
        -->
        <deepLink
            app:action="android.intent.action.VIEW"
            app:uri="f-droid.org/packages/{packageName}/" />
        <deepLink
            app:action="android.intent.action.VIEW"
            app:uri="f-droid.org/{locale}/packages/{packageName}/" />
        <deepLink
            app:action="android.intent.action.VIEW"
            app:uri="play.app.goo.gl/?link={playStore_url}?id={packageName}&amp;ddl={pc1}&amp;pcampaignid={pc2}" />

        <action
            android:id="@+id/action_applicationFragment_to_screenshotFragment"
            app:destination="@id/screenshotFragment" />
+73 −0
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.ui

import android.net.Uri
import com.google.common.truth.Truth.assertThat
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://f-droid.org/packages/org.fdroid.fdroid")
        )

        assertThat(packageName).isEqualTo("org.fdroid.fdroid")
    }

    @Test
    fun getPackageName_returnsPackageForLocalizedUrlWithTrailingSlash() {
        val packageName = FDroidDeepLinkParser.getPackageName(
            Uri.parse("https://f-droid.org/en/packages/org.fdroid.fdroid/")
        )

        assertThat(packageName).isEqualTo("org.fdroid.fdroid")
    }

    @Test
    fun getPackageName_returnsNullWhenPackageNameIsMissing() {
        val packageName = FDroidDeepLinkParser.getPackageName(
            Uri.parse("https://f-droid.org/en/packages/")
        )

        assertThat(packageName).isNull()
    }

    @Test
    fun getPackageName_returnsNullWhenPathHasExtraSegments() {
        val packageName = FDroidDeepLinkParser.getPackageName(
            Uri.parse("https://f-droid.org/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()
    }
}