From 93bdd05fe8be3b94681c20808c95ef4b7834f664 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 11:40:06 +0100 Subject: [PATCH 01/10] feat: ecosystem manager Signed-off-by: alperozturk96 --- .../ecosystem/AccountReceiverCallback.kt | 13 ++ .../utils/ecosystem/EcosystemAppPackage.kt | 14 ++ .../core/utils/ecosystem/EcosystemManager.kt | 156 ++++++++++++++++++ core/src/main/res/values/strings.xml | 8 + 4 files changed, 191 insertions(+) create mode 100644 core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt create mode 100644 core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt create mode 100644 core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt 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..2921adce --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils.ecosystem + +interface AccountReceiverCallback { + fun onAccountReceived(accountName: String) + fun onAccountError(reason: String) +} diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt new file mode 100644 index 00000000..a30901dc --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.core.utils.ecosystem + +enum class EcosystemAppPackage(name: String) { + FILES("com.nextcloud.client"), + NOTES("it.niedermann.owncloud.notes"), + TALK("com.nextcloud.talk2") +} 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..c51e4bd8 --- /dev/null +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt @@ -0,0 +1,156 @@ +/* + * 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.Intent +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) { + + private val tag = "EcosystemManager" + + /** + * Key used to pass account name e.g. abc@example.cloud.com + */ + private val keyAccount = "KEY_ACCOUNT" + + private val accountNamePattern = Pattern.compile( + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" + ) + + fun openApp(appPackage: EcosystemAppPackage, accountName: String?) { + Log.d(tag, "open app, package name: ${appPackage.name}, 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(appPackage) + return + } + + // validate account name + if (!accountNamePattern.matcher(accountName).matches()) { + showSnackbar(R.string.ecosystem_invalid_account) + return + } + + // validate package name + val intent = activity.packageManager.getLaunchIntentForPackage(appPackage.name) + if (intent == null) { + Log.w(tag, "given package name cannot be found") + showSnackbar(R.string.ecosystem_app_not_found) + openAppInStore(appPackage) + return + } + + try { + Log.d(tag, "launching app ${appPackage.name} with userHash=$accountName") + intent.putExtra(keyAccount, accountName) + activity.startActivity(intent) + } catch (e: Exception) { + showSnackbar(R.string.ecosystem_store_open_failed) + Log.e(tag, "exception launching app ${appPackage.name}: $e") + } + } + + private fun openAppInStore(appPackage: EcosystemAppPackage) { + Log.d(tag, "open app in store: $appPackage") + + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${appPackage.name}".toUri()) + + try { + activity.startActivity(intent) + } catch (_: ActivityNotFoundException) { + val webIntent = Intent( + Intent.ACTION_VIEW, + "https://play.google.com/store/apps/details?id=${appPackage.name}".toUri() + ) + + try { + activity.startActivity(webIntent) + } catch (e: Exception) { + showSnackbar(R.string.ecosystem_store_open_failed) + Log.e(tag, "No browser available to open store for ${appPackage.name}, exception: ", e) + } + } + } + + /** + * Receives account info from an intent and triggers the callback. + * + * @param intent The Intent received in onCreate() or onNewIntent() + * @param callback Callback to notify the client about success or failure + * + * Usage example in Activity: + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * setContentView(R.layout.activity_main) + * + * EcosystemManager(rootView = findViewById(R.id.root_layout)) + * .receiveAccount(intent, object : EcosystemManager.AccountReceiverCallback { + * override fun onAccountReceived(accountName: String) { + * // Use accountName for login or other actions + * Log.d("Receiver", "Received account: $accountName") + * } + * + * override fun onAccountError(reason: String) { + * // Show error or fallback + * Log.w("Receiver", "Error receiving account: $reason") + * } + * }) + * } + */ + fun receiveAccount(intent: Intent?, callback: AccountReceiverCallback) { + if (intent == null) { + Log.d(tag, "received intent is null") + val message = activity.getString(R.string.ecosystem_null_intent) + callback.onAccountError(message) + return + } + + val account = intent.getStringExtra(keyAccount) + + 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..accf35f6 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -15,4 +15,12 @@ %dh %dh + + + Account is missing. Cannot open the app. + Received intent is null. + Received account is invalid. + Invalid account format. Must be an email. + App not installed. Redirecting to store… + Cannot open app store. Please install manually. From 56025cd9b7be58360076e79843d88fc35ef11eb3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 12:49:24 +0100 Subject: [PATCH 02/10] feat: ecosystem manager Signed-off-by: alperozturk96 --- core/build.gradle | 1 + ...EcosystemAppPackage.kt => EcosystemApp.kt} | 2 +- .../core/utils/ecosystem/EcosystemManager.kt | 24 +++++++++---------- 3 files changed, 14 insertions(+), 13 deletions(-) rename core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/{EcosystemAppPackage.kt => EcosystemApp.kt} (86%) 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/EcosystemAppPackage.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt similarity index 86% rename from core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt rename to core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt index a30901dc..dd9aee1c 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemAppPackage.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.core.utils.ecosystem -enum class EcosystemAppPackage(name: String) { +enum class EcosystemApp(val packageName: String) { FILES("com.nextcloud.client"), NOTES("it.niedermann.owncloud.notes"), TALK("com.nextcloud.talk2") 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 index c51e4bd8..0228f582 100644 --- 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 @@ -39,14 +39,14 @@ class EcosystemManager(private val activity: Activity) { "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" ) - fun openApp(appPackage: EcosystemAppPackage, accountName: String?) { - Log.d(tag, "open app, package name: ${appPackage.name}, account name: $accountName") + fun openApp(app: EcosystemApp, accountName: String?) { + Log.d(tag, "open app, package name: ${app.packageName}, 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(appPackage) + openAppInStore(app) return } @@ -57,42 +57,42 @@ class EcosystemManager(private val activity: Activity) { } // validate package name - val intent = activity.packageManager.getLaunchIntentForPackage(appPackage.name) + val intent = activity.packageManager.getLaunchIntentForPackage(app.packageName) if (intent == null) { Log.w(tag, "given package name cannot be found") showSnackbar(R.string.ecosystem_app_not_found) - openAppInStore(appPackage) + openAppInStore(app) return } try { - Log.d(tag, "launching app ${appPackage.name} with userHash=$accountName") + Log.d(tag, "launching app ${app.name} with userHash=$accountName") intent.putExtra(keyAccount, accountName) activity.startActivity(intent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) - Log.e(tag, "exception launching app ${appPackage.name}: $e") + Log.e(tag, "exception launching app ${app.packageName}: $e") } } - private fun openAppInStore(appPackage: EcosystemAppPackage) { - Log.d(tag, "open app in store: $appPackage") + private fun openAppInStore(app: EcosystemApp) { + Log.d(tag, "open app in store: $app") - val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${appPackage.name}".toUri()) + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${app.packageName}".toUri()) try { activity.startActivity(intent) } catch (_: ActivityNotFoundException) { val webIntent = Intent( Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=${appPackage.name}".toUri() + "https://play.google.com/store/apps/details?id=${app.packageName}".toUri() ) try { activity.startActivity(webIntent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) - Log.e(tag, "No browser available to open store for ${appPackage.name}, exception: ", e) + Log.e(tag, "No browser available to open store for ${app.packageName}, exception: ", e) } } } From 84da5ef2ba2dd7fae3814815b7a4ddec4ac20bfd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 13:03:37 +0100 Subject: [PATCH 03/10] support multiple package name Signed-off-by: alperozturk96 --- .../core/utils/ecosystem/EcosystemApp.kt | 25 +++++++++++++--- .../core/utils/ecosystem/EcosystemManager.kt | 29 +++++++++++++++---- 2 files changed, 44 insertions(+), 10 deletions(-) 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 index dd9aee1c..4fb50782 100644 --- 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 @@ -7,8 +7,25 @@ package com.nextcloud.android.common.core.utils.ecosystem -enum class EcosystemApp(val packageName: String) { - FILES("com.nextcloud.client"), - NOTES("it.niedermann.owncloud.notes"), - TALK("com.nextcloud.talk2") +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 index 0228f582..1f612339 100644 --- 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 @@ -9,7 +9,9 @@ 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 @@ -40,7 +42,7 @@ class EcosystemManager(private val activity: Activity) { ) fun openApp(app: EcosystemApp, accountName: String?) { - Log.d(tag, "open app, package name: ${app.packageName}, account name: $accountName") + Log.d(tag, "open app, package name: ${app.packageNames}, account name: $accountName") // check account name emptiness if (accountName.isNullOrBlank()) { @@ -57,7 +59,7 @@ class EcosystemManager(private val activity: Activity) { } // validate package name - val intent = activity.packageManager.getLaunchIntentForPackage(app.packageName) + val intent = activity.getLaunchIntentForPackages(app.packageNames) if (intent == null) { Log.w(tag, "given package name cannot be found") showSnackbar(R.string.ecosystem_app_not_found) @@ -71,28 +73,43 @@ class EcosystemManager(private val activity: Activity) { activity.startActivity(intent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) - Log.e(tag, "exception launching app ${app.packageName}: $e") + 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.getLaunchIntentForPackages( + packageNames: List + ): Intent? { + val pm: PackageManager = packageManager + return packageNames.firstNotNullOfOrNull { packageName -> + pm.getLaunchIntentForPackage(packageName) } } private fun openAppInStore(app: EcosystemApp) { Log.d(tag, "open app in store: $app") - val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${app.packageName}".toUri()) + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${app.packageNames}".toUri()) try { activity.startActivity(intent) } catch (_: ActivityNotFoundException) { val webIntent = Intent( Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=${app.packageName}".toUri() + "https://play.google.com/store/apps/details?id=${app.packageNames}".toUri() ) try { activity.startActivity(webIntent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) - Log.e(tag, "No browser available to open store for ${app.packageName}, exception: ", e) + Log.e(tag, "No browser available to open store for ${app.packageNames}, exception: ", e) } } } From 6fd17909d0aeaed3ba6c06cae71b794f0cd67bfb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 14:24:23 +0100 Subject: [PATCH 04/10] add intent action Signed-off-by: alperozturk96 --- .../core/utils/ecosystem/EcosystemManager.kt | 49 +++++++++++-------- core/src/main/res/values/strings.xml | 1 + 2 files changed, 29 insertions(+), 21 deletions(-) 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 index 1f612339..49a8b03b 100644 --- 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 @@ -37,6 +37,8 @@ class EcosystemManager(private val activity: Activity) { */ private val keyAccount = "KEY_ACCOUNT" + private val ecoSystemIntentAction = "com.nextcloud.intent.OPEN_ECOSYSTEM_APP" + private val accountNamePattern = Pattern.compile( "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" ) @@ -68,8 +70,10 @@ class EcosystemManager(private val activity: Activity) { } try { - Log.d(tag, "launching app ${app.name} with userHash=$accountName") + Log.d(tag, "launching app ${app.name} with account=$accountName") + intent.action = ecoSystemIntentAction intent.putExtra(keyAccount, accountName) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) activity.startActivity(intent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) @@ -115,32 +119,28 @@ class EcosystemManager(private val activity: Activity) { } /** - * Receives account info from an intent and triggers the callback. - * - * @param intent The Intent received in onCreate() or onNewIntent() - * @param callback Callback to notify the client about success or failure + * Receives account from an intent and triggers the callback. * - * Usage example in Activity: + * This method should be called from your Activity's `onNewIntent()` + * to handle incoming ecosystem intents from other ecosystem apps. * - * override fun onCreate(savedInstanceState: Bundle?) { - * super.onCreate(savedInstanceState) - * setContentView(R.layout.activity_main) + * Important: + * 1. The sending app calls openApp. + * 2. The receiving app must declare an intent-filter in its manifest to handle this action: * - * EcosystemManager(rootView = findViewById(R.id.root_layout)) - * .receiveAccount(intent, object : EcosystemManager.AccountReceiverCallback { - * override fun onAccountReceived(accountName: String) { - * // Use accountName for login or other actions - * Log.d("Receiver", "Received account: $accountName") - * } + * + * + * + * + * + * * - * override fun onAccountError(reason: String) { - * // Show error or fallback - * Log.w("Receiver", "Error receiving account: $reason") - * } - * }) - * } */ 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) @@ -148,6 +148,13 @@ class EcosystemManager(private val activity: Activity) { return } + if (intent.action != ecoSystemIntentAction) { + 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(keyAccount) if (account.isNullOrBlank()) { diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index accf35f6..0e19f13c 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ 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… From 0af8a13c4a60a9c5d1695bb1207d3e0512dda656 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 15:10:07 +0100 Subject: [PATCH 05/10] add intent action Signed-off-by: alperozturk96 --- .../core/utils/ecosystem/EcosystemManager.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 index 49a8b03b..7c33d2fa 100644 --- 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 @@ -71,10 +71,12 @@ class EcosystemManager(private val activity: Activity) { try { Log.d(tag, "launching app ${app.name} with account=$accountName") - intent.action = ecoSystemIntentAction - intent.putExtra(keyAccount, accountName) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - activity.startActivity(intent) + val launchIntent = Intent(ecoSystemIntentAction).apply { + setPackage(intent.`package`) + putExtra(keyAccount, 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") @@ -99,21 +101,23 @@ class EcosystemManager(private val activity: Activity) { private fun openAppInStore(app: EcosystemApp) { Log.d(tag, "open app in store: $app") - val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${app.packageNames}".toUri()) + val firstPackageName = app.packageNames.firstOrNull() ?: return + + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=${firstPackageName}".toUri()) try { activity.startActivity(intent) } catch (_: ActivityNotFoundException) { val webIntent = Intent( Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=${app.packageNames}".toUri() + "https://play.google.com/store/apps/details?id=${firstPackageName}".toUri() ) try { activity.startActivity(webIntent) } catch (e: Exception) { showSnackbar(R.string.ecosystem_store_open_failed) - Log.e(tag, "No browser available to open store for ${app.packageNames}, exception: ", e) + Log.e(tag, "No browser available to open store for ${firstPackageName}, exception: ", e) } } } From b59722dc010765b4d252937be2eeb87a3d6c2482 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 15:30:45 +0100 Subject: [PATCH 06/10] fix codacy Signed-off-by: alperozturk96 --- .../ecosystem/AccountReceiverCallback.kt | 5 ++ .../core/utils/ecosystem/EcosystemApp.kt | 28 ++++++---- .../core/utils/ecosystem/EcosystemManager.kt | 54 +++++++++++-------- 3 files changed, 56 insertions(+), 31 deletions(-) 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 index 2921adce..c655777f 100644 --- 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 @@ -7,7 +7,12 @@ 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 index 4fb50782..3d89bcd4 100644 --- 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 @@ -7,25 +7,35 @@ package com.nextcloud.android.common.core.utils.ecosystem -enum class EcosystemApp(val packageNames: List) { +/** + * 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 + "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 + "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 + "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 index 7c33d2fa..636ad320 100644 --- 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 @@ -28,8 +28,9 @@ import java.util.regex.Pattern * - Redirecting to Play Store if app not installed * - Receiving account info from intents with callback support */ -class EcosystemManager(private val activity: Activity) { - +class EcosystemManager( + private val activity: Activity +) { private val tag = "EcosystemManager" /** @@ -39,11 +40,16 @@ class EcosystemManager(private val activity: Activity) { private val ecoSystemIntentAction = "com.nextcloud.intent.OPEN_ECOSYSTEM_APP" - private val accountNamePattern = Pattern.compile( - "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" - ) + private val accountNamePattern = + Pattern.compile( + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" + ) - fun openApp(app: EcosystemApp, accountName: String?) { + @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 @@ -71,11 +77,12 @@ class EcosystemManager(private val activity: Activity) { try { Log.d(tag, "launching app ${app.name} with account=$accountName") - val launchIntent = Intent(ecoSystemIntentAction).apply { - setPackage(intent.`package`) - putExtra(keyAccount, accountName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } + val launchIntent = + Intent(ecoSystemIntentAction).apply { + setPackage(intent.`package`) + putExtra(keyAccount, 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) @@ -83,41 +90,40 @@ class EcosystemManager(private val activity: Activity) { } } - /** * 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.getLaunchIntentForPackages( - packageNames: List - ): Intent? { + private fun Context.getLaunchIntentForPackages(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 intent = Intent(Intent.ACTION_VIEW, "market://details?id=${firstPackageName}".toUri()) + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=$firstPackageName".toUri()) try { activity.startActivity(intent) } catch (_: ActivityNotFoundException) { - val webIntent = Intent( - Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=${firstPackageName}".toUri() - ) + val webIntent = + Intent( + Intent.ACTION_VIEW, + "https://play.google.com/store/apps/details?id=$firstPackageName".toUri() + ) 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) + Log.e(tag, "No browser available to open store for $firstPackageName, exception: ", e) } } } @@ -142,7 +148,11 @@ class EcosystemManager(private val activity: Activity) { * * */ - fun receiveAccount(intent: Intent?, callback: AccountReceiverCallback) { + @Suppress("ReturnCount") + fun receiveAccount( + intent: Intent?, + callback: AccountReceiverCallback + ) { Log.d(tag, "receive account started") if (intent == null) { From 4552d6bba169f23b10f002eb861911df2c81a294 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 15 Jan 2026 15:32:30 +0100 Subject: [PATCH 07/10] fix codacy Signed-off-by: alperozturk96 --- .../android/common/core/utils/ecosystem/EcosystemManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 636ad320..b6bf93f7 100644 --- 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 @@ -131,7 +131,7 @@ class EcosystemManager( /** * Receives account from an intent and triggers the callback. * - * This method should be called from your Activity's `onNewIntent()` + * This method should be called from your Activity's `onCreate()` and `onNewIntent()` * to handle incoming ecosystem intents from other ecosystem apps. * * Important: From 5d232fe1c0d59f9b62910ff1f9ccdcb28a4ff296 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:27:26 +0100 Subject: [PATCH 08/10] fix codacy Signed-off-by: alperozturk96 --- .../core/utils/ecosystem/EcosystemManager.kt | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) 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 index b6bf93f7..60339ab7 100644 --- 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 @@ -31,30 +31,44 @@ import java.util.regex.Pattern class EcosystemManager( private val activity: Activity ) { - private val tag = "EcosystemManager" + companion object { + private const val TAG = "EcosystemManager" - /** - * Key used to pass account name e.g. abc@example.cloud.com - */ - private val keyAccount = "KEY_ACCOUNT" - - private val ecoSystemIntentAction = "com.nextcloud.intent.OPEN_ECOSYSTEM_APP" + 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" + } private val accountNamePattern = Pattern.compile( "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-z]{2,}" ) + /** + * 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") + 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") + Log.w(TAG, "given account name is null") showSnackbar(R.string.ecosystem_null_account) openAppInStore(app) return @@ -67,26 +81,26 @@ class EcosystemManager( } // validate package name - val intent = activity.getLaunchIntentForPackages(app.packageNames) + val intent = activity.findLaunchIntentForInstalledPackage(app.packageNames) if (intent == null) { - Log.w(tag, "given package name cannot be found") + 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") + Log.d(TAG, "launching app ${app.name} with account=$accountName") val launchIntent = - Intent(ecoSystemIntentAction).apply { + Intent(ECOSYSTEM_INTENT_ACTION).apply { setPackage(intent.`package`) - putExtra(keyAccount, accountName) + 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") + Log.e(TAG, "exception launching app ${app.packageNames}: $e") } } @@ -95,7 +109,7 @@ class EcosystemManager( * * @return launch Intent or null if none of the apps are installed */ - private fun Context.getLaunchIntentForPackages(packageNames: List): Intent? { + private fun Context.findLaunchIntentForInstalledPackage(packageNames: List): Intent? { val pm: PackageManager = packageManager return packageNames.firstNotNullOfOrNull { packageName -> pm.getLaunchIntentForPackage(packageName) @@ -104,26 +118,23 @@ class EcosystemManager( @Suppress("TooGenericExceptionCaught") private fun openAppInStore(app: EcosystemApp) { - Log.d(tag, "open app in store: $app") + Log.d(TAG, "open app in store: $app") val firstPackageName = app.packageNames.firstOrNull() ?: return - val intent = Intent(Intent.ACTION_VIEW, "market://details?id=$firstPackageName".toUri()) + val marketUri = "$PLAY_STORE_MARKET_LINK$firstPackageName".toUri() + val intent = Intent(Intent.ACTION_VIEW, marketUri) try { activity.startActivity(intent) } catch (_: ActivityNotFoundException) { - val webIntent = - Intent( - Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=$firstPackageName".toUri() - ) - + 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) + Log.e(TAG, "No browser available to open store for $firstPackageName, exception: ", e) } } } @@ -153,23 +164,23 @@ class EcosystemManager( intent: Intent?, callback: AccountReceiverCallback ) { - Log.d(tag, "receive account started") + Log.d(TAG, "receive account started") if (intent == null) { - Log.d(tag, "received intent is null") + Log.d(TAG, "received intent is null") val message = activity.getString(R.string.ecosystem_null_intent) callback.onAccountError(message) return } - if (intent.action != ecoSystemIntentAction) { - Log.d(tag, "received intent action is not matching") + 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(keyAccount) + val account = intent.getStringExtra(EXTRA_KEY_ACCOUNT) if (account.isNullOrBlank()) { val message = activity.getString(R.string.ecosystem_null_account) @@ -183,7 +194,7 @@ class EcosystemManager( return } - Log.d(tag, "Account received from intent: $account") + Log.d(TAG, "Account received from intent: $account") callback.onAccountReceived(account) } From 4bf04fd3b4eb4c74cb98d9270d0b184a80ef6af1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:32:00 +0100 Subject: [PATCH 09/10] fix codacy Signed-off-by: alperozturk96 --- .../core/utils/ecosystem/EcosystemManager.kt | 7 +-- .../core/utils/AccountNamePatternTest.kt | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt 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 index 60339ab7..18555fe7 100644 --- 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 @@ -38,12 +38,11 @@ class EcosystemManager( 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( - "[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. 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()) + } +} From 30a17d42f4ca2c7fa8a66c4e0d1d523a45e16afe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 16 Jan 2026 09:36:10 +0100 Subject: [PATCH 10/10] add EcosystemAppTest Signed-off-by: alperozturk96 --- .../common/core/utils/EcosystemAppTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt 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() + ) + } +}