From e6e4dbb6d683840448d4257fa8e2e1072bd55def Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 19 Apr 2026 09:46:09 +0200 Subject: [PATCH] Add unique app instance ID for Android (#1930) --- .../aliasvault/app/webapi/WebApiService.kt | 23 ++++++++++++-- .../AliasVault.Api/Helpers/AuthHelper.cs | 31 ++++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt index 0a6674394..f19fa2e4d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt @@ -34,12 +34,29 @@ class WebApiService(private val context: Context) { private const val API_URL_KEY = "apiUrl" private const val ACCESS_TOKEN_KEY = "accessToken" private const val REFRESH_TOKEN_KEY = "refreshToken" + private const val APP_INSTANCE_ID_KEY = "appInstanceId" private const val DEFAULT_API_URL = "https://app.aliasvault.net/api" private const val SHARED_PREFS_NAME = "aliasvault" } private val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) + /** + * Unique app instance ID for this app installation/Android user profile. + * This is generated once and persists to differentiate between multiple + * Android User Profiles on the same device. + */ + private val appInstanceId: String by lazy { + var id = sharedPreferences.getString(APP_INSTANCE_ID_KEY, null) + if (id == null) { + // Generate a random UUID and remove dashes to avoid conflicts with header format + id = java.util.UUID.randomUUID().toString().replace("-", "") + sharedPreferences.edit().putString(APP_INSTANCE_ID_KEY, id).apply() + Log.d(TAG, "Generated new app instance ID: $id") + } + id + } + // MARK: - Configuration Management /** @@ -309,16 +326,18 @@ class WebApiService(private val context: Context) { /** * Get the client version header value. + * Format: "android-{version}-{appInstanceId}" + * The app instance ID uniquely identifies this app installation/Android user profile. */ private fun getClientVersionHeader(): String { return try { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val version = packageInfo.versionName ?: "0.0.0" val baseVersion = version.split("-").firstOrNull() ?: "0.0.0" - "android-$baseVersion" + "android-$baseVersion-$appInstanceId" } catch (e: PackageManager.NameNotFoundException) { Log.e(TAG, "Error getting package version", e) - "android-0.0.0" + "android-0.0.0-$appInstanceId" } } diff --git a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs index 6123c6a48..bf0570705 100644 --- a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs +++ b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs @@ -86,9 +86,18 @@ public static class AuthHelper /// conflicts when a user is logged in on multiple clients from the same browser/device. /// For example, logging out from the browser extension won't affect the web app session. /// - /// NOTE: current implementation means that only one refresh token can be valid for a - /// specific user/device combo at a time. The identifier generation could be made more unique in the future - /// to prevent any potential unwanted conflicts. + /// For Android, the identifier also includes an app instance ID at the end to support + /// multiple Android User Profiles on the same physical device. Each Android User Profile + /// generates a unique UUID (without dashes) on first launch that persists for the lifetime + /// of that installation. + /// + /// Device identifier format examples: + /// - Web/Browser: "chrome|Mozilla/5.0...|en-US" + /// - Android: "android|Dalvik/2.1.0...|en-US|550e8400e29b41d4a716446655440000" + /// - iOS: "ios|AliasVault/1.0...|en-US" + /// + /// NOTE: This implementation ensures only one refresh token can be valid for a + /// specific user/device combo at a time. /// /// The HttpRequest instance for the request that the client used. /// Unique device identifier as string. @@ -97,11 +106,19 @@ public static class AuthHelper var userAgent = request.Headers.UserAgent.ToString(); var acceptLanguage = request.Headers.AcceptLanguage.ToString(); - // Client header is usually formatted like "[client name]-[version]" e.g. "chrome-0.25.0", take only "chrome" + // Client header is formatted like "[client name]-[version]" or "[client name]-[version]-[app-instance-id]" + // Examples: "chrome-0.25.0", "android-0.29.0-550e8400e29b41d4a716446655440000" var clientHeader = request.Headers["X-AliasVault-Client"].ToString(); - var clientName = clientHeader?.Split('-')[0] ?? "unknown"; + var clientParts = clientHeader?.Split('-') ?? []; + var clientName = clientParts.Length > 0 ? clientParts[0] : "unknown"; - var rawIdentifier = $"{clientName}|{userAgent}|{acceptLanguage}"; - return rawIdentifier; + // For Android, extract app instance ID if present (UUID without dashes as 3rd part) + var appInstanceSuffix = string.Empty; + if (clientName == "android" && clientParts.Length >= 3) + { + appInstanceSuffix = $"|{clientParts[2]}"; + } + + return $"{clientName}|{userAgent}|{acceptLanguage}{appInstanceSuffix}"; } }