diff --git a/core/build.gradle b/core/build.gradle index 1aab7899..9e34ce61 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -12,6 +12,7 @@ plugins { } android { + namespace 'com.nextcloud.android.common.core' defaultConfig { minSdk = 21 compileSdk = 36 diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt new file mode 100644 index 00000000..c655777f --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils.ecosystem + +/** + * Callback interface for receiving account information from another Nextcloud app. + * + */ +interface AccountReceiverCallback { + fun onAccountReceived(accountName: String) + + fun onAccountError(reason: String) +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt new file mode 100644 index 00000000..3d89bcd4 --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils.ecosystem + +/** + * Represents Nextcloud ecosystem apps that can communicate with each other. + * + * Each enum value corresponds to a specific app in the Nextcloud ecosystem, + * and holds a list of package names for that app. Multiple package names + * allow compatibility with different flavours (Play Store, F-Droid, QA, beta/dev versions, etc.). + * + */ +enum class EcosystemApp( + val packageNames: List +) { + FILES( + listOf( + "com.nextcloud.client", // generic, gplay, huawei + "com.nextcloud.android.beta", // versionDev + "com.nextcloud.android.qa" // qa + ) + ), + NOTES( + listOf( + "it.niedermann.owncloud.notes", // play, fdroid + "it.niedermann.owncloud.notes.dev", // dev + "it.niedermann.owncloud.notes.qa" // qa + ) + ), + TALK( + listOf( + "com.nextcloud.talk2", // generic, gplay + "com.nextcloud.talk2.qa" // qa + ) + ) +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt new file mode 100644 index 00000000..18555fe7 --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt @@ -0,0 +1,204 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils.ecosystem + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log +import android.view.View +import androidx.core.net.toUri +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.core.R +import java.util.regex.Pattern + +/** + * EcosystemManager: handles sending and receiving account info across apps + * within the Nextcloud ecosystem (Notes, Files, Talk). + * + * Supports: + * - Opening apps with account info + * - Redirecting to Play Store if app not installed + * - Receiving account info from intents with callback support + */ +class EcosystemManager( + private val activity: Activity +) { + companion object { + private const val TAG = "EcosystemManager" + + private const val ECOSYSTEM_INTENT_ACTION = "com.nextcloud.intent.OPEN_ECOSYSTEM_APP" + private const val PLAY_STORE_LINK = "https://play.google.com/store/apps/details?id=" + private const val PLAY_STORE_MARKET_LINK = "market://details?id=" + private const val EXTRA_KEY_ACCOUNT = "KEY_ACCOUNT" + + const val ACCOUNT_NAME_PATTERN_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" + } + + private val accountNamePattern = Pattern.compile(ACCOUNT_NAME_PATTERN_REGEX) + + /** + * Opens an ecosystem app with the given account information. + * + * If the target app is installed, it will be launched using the + * {@link #ECOSYSTEM_INTENT_ACTION} intent action and the account name + * will be passed as an intent extra. + * + * If the app is not installed or cannot be launched, the user will be + * redirected to the Google Play Store page for the app. + * + * @param app The ecosystem app to be opened (e.g. Notes, Files, Talk) + * @param accountName The account name associated with the user, + * e.g. "abc@example.cloud.com" + */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") + fun openApp( + app: EcosystemApp, + accountName: String? + ) { + Log.d(TAG, "open app, package name: ${app.packageNames}, account name: $accountName") + + // check account name emptiness + if (accountName.isNullOrBlank()) { + Log.w(TAG, "given account name is null") + showSnackbar(R.string.ecosystem_null_account) + openAppInStore(app) + return + } + + // validate account name + if (!accountNamePattern.matcher(accountName).matches()) { + showSnackbar(R.string.ecosystem_invalid_account) + return + } + + // validate package name + val intent = activity.findLaunchIntentForInstalledPackage(app.packageNames) + if (intent == null) { + Log.w(TAG, "given package name cannot be found") + showSnackbar(R.string.ecosystem_app_not_found) + openAppInStore(app) + return + } + + try { + Log.d(TAG, "launching app ${app.name} with account=$accountName") + val launchIntent = + Intent(ECOSYSTEM_INTENT_ACTION).apply { + setPackage(intent.`package`) + putExtra(EXTRA_KEY_ACCOUNT, accountName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + activity.startActivity(launchIntent) + } catch (e: Exception) { + showSnackbar(R.string.ecosystem_store_open_failed) + Log.e(TAG, "exception launching app ${app.packageNames}: $e") + } + } + + /** + * Finds the first launchable intent from a list of package names. + * + * @return launch Intent or null if none of the apps are installed + */ + private fun Context.findLaunchIntentForInstalledPackage(packageNames: List): Intent? { + val pm: PackageManager = packageManager + return packageNames.firstNotNullOfOrNull { packageName -> + pm.getLaunchIntentForPackage(packageName) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun openAppInStore(app: EcosystemApp) { + Log.d(TAG, "open app in store: $app") + + val firstPackageName = app.packageNames.firstOrNull() ?: return + + val marketUri = "$PLAY_STORE_MARKET_LINK$firstPackageName".toUri() + val intent = Intent(Intent.ACTION_VIEW, marketUri) + + try { + activity.startActivity(intent) + } catch (_: ActivityNotFoundException) { + val playStoreUri = "$PLAY_STORE_LINK$firstPackageName".toUri() + val webIntent = Intent(Intent.ACTION_VIEW, playStoreUri) + try { + activity.startActivity(webIntent) + } catch (e: Exception) { + showSnackbar(R.string.ecosystem_store_open_failed) + Log.e(TAG, "No browser available to open store for $firstPackageName, exception: ", e) + } + } + } + + /** + * Receives account from an intent and triggers the callback. + * + * This method should be called from your Activity's `onCreate()` and `onNewIntent()` + * to handle incoming ecosystem intents from other ecosystem apps. + * + * Important: + * 1. The sending app calls openApp. + * 2. The receiving app must declare an intent-filter in its manifest to handle this action: + * + * + * + * + * + * + * + * + */ + @Suppress("ReturnCount") + fun receiveAccount( + intent: Intent?, + callback: AccountReceiverCallback + ) { + Log.d(TAG, "receive account started") + + if (intent == null) { + Log.d(TAG, "received intent is null") + val message = activity.getString(R.string.ecosystem_null_intent) + callback.onAccountError(message) + return + } + + if (intent.action != ECOSYSTEM_INTENT_ACTION) { + Log.d(TAG, "received intent action is not matching") + val message = activity.getString(R.string.ecosystem_received_intent_action_not_matching) + callback.onAccountError(message) + return + } + + val account = intent.getStringExtra(EXTRA_KEY_ACCOUNT) + + if (account.isNullOrBlank()) { + val message = activity.getString(R.string.ecosystem_null_account) + callback.onAccountError(message) + return + } + + if (!accountNamePattern.matcher(account).matches()) { + val message = activity.getString(R.string.ecosystem_received_account_invalid) + callback.onAccountError(message) + return + } + + Log.d(TAG, "Account received from intent: $account") + callback.onAccountReceived(account) + } + + private fun showSnackbar(messageRes: Int) { + val rootContent = activity.findViewById(android.R.id.content) + Snackbar.make(rootContent, activity.getString(messageRes), Snackbar.LENGTH_LONG).show() + } +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 7aff6e87..0e19f13c 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -15,4 +15,13 @@ %dh %dh + + + Account is missing. Cannot open the app. + Received intent is null. + Received intent is not matching. + Received account is invalid. + Invalid account format. Must be an email. + App not installed. Redirecting to store… + Cannot open app store. Please install manually. diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt new file mode 100644 index 00000000..e725a7f8 --- /dev/null +++ b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils + +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.regex.Pattern + +/** + * Unit tests for validating account name format using the same + * regular expression as {@link EcosystemManager}. + * + * These tests verify that: + * - Valid account names (e.g. "abc@example.cloud.com") are accepted + * - Invalid or malformed account names are rejected + * - Edge cases such as empty or blank strings do not match + * + * The account name is expected to follow an email-like format and is + * used when passing account information between ecosystem apps. + */ +class AccountNamePatternTest { + private val pattern = Pattern.compile(EcosystemManager.ACCOUNT_NAME_PATTERN_REGEX) + + @Test + fun `valid account names should match`() { + assertTrue(pattern.matcher("abc@example.cloud.com").matches()) + assertTrue(pattern.matcher("user.name+test@sub.domain.org").matches()) + assertTrue(pattern.matcher("user_123@test.co").matches()) + } + + @Test + fun `invalid account names should not match`() { + assertFalse(pattern.matcher("abc").matches()) + assertFalse(pattern.matcher("abc@").matches()) + assertFalse(pattern.matcher("abc@example").matches()) + assertFalse(pattern.matcher("abc@example.").matches()) + assertFalse(pattern.matcher("abc@.com").matches()) + assertFalse(pattern.matcher("@example.com").matches()) + assertFalse(pattern.matcher("abc@example.c").matches()) + assertFalse(pattern.matcher("abc example@test.com").matches()) + } + + @Test + fun `empty or blank account names should not match`() { + assertFalse(pattern.matcher("").matches()) + assertFalse(pattern.matcher(" ").matches()) + } +} diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt new file mode 100644 index 00000000..628009d3 --- /dev/null +++ b/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils + +import com.nextcloud.android.common.core.utils.ecosystem.EcosystemApp +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for {@link EcosystemApp}. + * + * These tests ensure that the order of package names is preserved and that + * the first entry for each ecosystem app always represents the production + * package name. + * + * The order is important because the first package name is used when + * redirecting users to the Play Store or resolving fallback behavior. + */ +class EcosystemAppTest { + @Test + fun `first package name must always be the production package`() { + assertEquals( + "com.nextcloud.client", + EcosystemApp.FILES.packageNames.first() + ) + + assertEquals( + "it.niedermann.owncloud.notes", + EcosystemApp.NOTES.packageNames.first() + ) + + assertEquals( + "com.nextcloud.talk2", + EcosystemApp.TALK.packageNames.first() + ) + } +}