Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ plugins {
}

android {
namespace 'com.nextcloud.android.common.core'
defaultConfig {
minSdk = 21
compileSdk = 36
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String>
) {
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
)
)
}
Original file line number Diff line number Diff line change
@@ -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<String>): 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:
*
* <activity android:name=".ui.activity.MainActivity"
* android:exported="true"
* android:launchMode="singleTop">
* <intent-filter>
* <action android:name="com.nextcloud.intent.OPEN_ECOSYSTEM_APP" />
* <category android:name="android.intent.category.DEFAULT" />
* </intent-filter>
* </activity>
*
*/
@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<View>(android.R.id.content)
Snackbar.make(rootContent, activity.getString(messageRes), Snackbar.LENGTH_LONG).show()
}
}
9 changes: 9 additions & 0 deletions core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,13 @@
<item quantity="one">%dh</item>
<item quantity="other">%dh</item>
</plurals>


<string name="ecosystem_null_account">Account is missing. Cannot open the app.</string>
<string name="ecosystem_null_intent">Received intent is null.</string>
<string name="ecosystem_received_intent_action_not_matching">Received intent is not matching.</string>
<string name="ecosystem_received_account_invalid">Received account is invalid.</string>
<string name="ecosystem_invalid_account">Invalid account format. Must be an email.</string>
<string name="ecosystem_app_not_found">App not installed. Redirecting to store…</string>
<string name="ecosystem_store_open_failed">Cannot open app store. Please install manually.</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
Loading