refactor(auth): derive google-linked nsecs deterministically
Replaces the Google Drive backup flow (#528) with deterministic key derivation. The user's Nostr identity IS their Google account — no encrypted blobs to store, no backup events to publish, nothing for Google or any third party to retain. privkey = SHA-256("wisp-account-v1:" || sub || ":" || accountIndex) Properties: - Same Google account always derives the same nsec on any device - No backup to lose: signing in regenerates the keys - No `drive.appdata` OAuth scope, no scary Drive consent dialog - Anyone with access to the Google account can derive every nsec. Bounded by Google account security — same trade-off as #528, with a much simpler attack surface and no third-party storage layer Discovery on sign-in: - Derive candidate keypairs for indices 0..15 from the user's `sub` - One REQ to relay.damus.io, relay.primal.net, nos.lol, nostr.wine, relay.wisp.talk, relay.ditto.pub asking for kind 0/3/10002 events from those pubkeys - Pubkeys with any activity = "in use" accounts that go in the chooser; avatar + display name come from the same kind-0 events - "Create another account" derives the next-unused index Code shrinkage: DriveBackupService is gone, BackupCrypto's encryption helpers are gone, the play-services-auth dependency is gone, and the Drive-related ProGuard rules are gone. The whole flow is ~200 fewer lines than #528 and easier to audit — the derivation is one line of SHA-256. Splash button switches to Google's dark-mode brand variant (#131314 container, full-color G, #8E918F stroke) per Sign in with Google spec. No migration needed: nobody is on the #528 flow yet.
This commit is contained in:
@@ -117,5 +117,4 @@ dependencies {
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.play.services.auth)
|
||||
implementation(libs.googleid)
|
||||
implementation(libs.play.services.auth)
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -68,5 +68,3 @@
|
||||
-dontwarn androidx.credentials.**
|
||||
-keep class com.google.android.libraries.identity.googleid.** { *; }
|
||||
-dontwarn com.google.android.libraries.identity.googleid.**
|
||||
-keep class com.google.android.gms.auth.** { *; }
|
||||
-dontwarn com.google.android.gms.auth.**
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package com.wisp.app.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Minimal Drive REST v3 client targeted at the user's appDataFolder.
|
||||
*
|
||||
* One backup file per Nostr account. Filenames follow `wisp_nsec_<npub>.bin`
|
||||
* so we can list every account on the user's Drive and surface the npub
|
||||
* without downloading each file. Legacy single-file backups (`wisp_nsec.bin`,
|
||||
* from before multi-account support) are also surfaced in the list.
|
||||
*/
|
||||
class DriveBackupService(
|
||||
private val httpClient: OkHttpClient = OkHttpClient()
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
data class BackupFile(val fileId: String, val name: String) {
|
||||
/** `npub1…` parsed from the filename, or null for the legacy unnamed backup. */
|
||||
val npubFromName: String?
|
||||
get() = when {
|
||||
name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_SUFFIX) -> {
|
||||
name.removePrefix(BACKUP_PREFIX).removeSuffix(BACKUP_SUFFIX)
|
||||
.takeIf { it.isNotEmpty() && it.startsWith("npub1") }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listBackups(accessToken: String): List<BackupFile> = withContext(Dispatchers.IO) {
|
||||
val nameQuery = "name = '$LEGACY_FILENAME' or name contains '$BACKUP_PREFIX'"
|
||||
val url = "https://www.googleapis.com/drive/v3/files" +
|
||||
"?spaces=appDataFolder" +
|
||||
"&q=" + java.net.URLEncoder.encode(nameQuery, "UTF-8") +
|
||||
"&fields=files(id,name,modifiedTime)" +
|
||||
"&pageSize=100"
|
||||
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
httpClient.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Drive list failed: ${response.code} ${response.message}")
|
||||
}
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val root = json.parseToJsonElement(body) as? JsonObject ?: return@withContext emptyList()
|
||||
val files = root["files"]?.jsonArray ?: return@withContext emptyList()
|
||||
files.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj["id"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
||||
val name = obj["name"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
||||
BackupFile(id, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadBackup(accessToken: String, fileId: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder()
|
||||
.url("https://www.googleapis.com/drive/v3/files/$fileId?alt=media")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
httpClient.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Drive download failed: ${response.code} ${response.message}")
|
||||
}
|
||||
response.body?.string() ?: throw IOException("Empty download body")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new backup file in appDataFolder. If a backup for the same npub
|
||||
* already exists, deletes it first so we keep one file per account rather
|
||||
* than accumulating duplicate revisions.
|
||||
*/
|
||||
suspend fun uploadBackup(accessToken: String, npub: String, payload: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
require(npub.startsWith("npub1")) { "npub must be bech32-encoded" }
|
||||
val filename = "$BACKUP_PREFIX$npub$BACKUP_SUFFIX"
|
||||
|
||||
// Remove any existing file with the same name to avoid duplicates.
|
||||
listBackups(accessToken).filter { it.name == filename }.forEach { existing ->
|
||||
deleteBackup(accessToken, existing.fileId)
|
||||
}
|
||||
|
||||
val metadata = """{"name":"$filename","parents":["$APP_DATA_FOLDER"]}"""
|
||||
val boundary = "wisp-${UUID.randomUUID()}"
|
||||
val crlf = "\r\n"
|
||||
val body = buildString {
|
||||
append("--").append(boundary).append(crlf)
|
||||
append("Content-Type: application/json; charset=UTF-8").append(crlf).append(crlf)
|
||||
append(metadata).append(crlf)
|
||||
append("--").append(boundary).append(crlf)
|
||||
append("Content-Type: application/octet-stream").append(crlf).append(crlf)
|
||||
append(payload).append(crlf)
|
||||
append("--").append(boundary).append("--").append(crlf)
|
||||
}
|
||||
|
||||
val requestBody: RequestBody = body.toRequestBody(
|
||||
"multipart/related; boundary=$boundary".toMediaType()
|
||||
)
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
httpClient.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Drive upload failed: ${response.code} ${response.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteBackup(accessToken: String, fileId: String) = withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder()
|
||||
.url("https://www.googleapis.com/drive/v3/files/$fileId")
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.delete()
|
||||
.build()
|
||||
httpClient.newCall(req).execute().close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val APP_DATA_FOLDER = "appDataFolder"
|
||||
private const val BACKUP_PREFIX = "wisp_nsec_"
|
||||
private const val BACKUP_SUFFIX = ".bin"
|
||||
private const val LEGACY_FILENAME = "wisp_nsec.bin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.wisp.app.auth
|
||||
|
||||
import com.wisp.app.nostr.Keys
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Deterministically derives Nostr keypairs from a Google ID token's `sub`
|
||||
* claim. Account #0 is the first identity, #1 is the second, and so on —
|
||||
* each one is independent and recoverable from the same Google login alone.
|
||||
*
|
||||
* privkey = SHA-256("wisp-account-v1:" || sub || ":" || accountIndex)
|
||||
*
|
||||
* Properties:
|
||||
* - Stable: the same Google account always produces the same Nostr keypair.
|
||||
* - No backup: nothing to store anywhere; signing in regenerates the keys.
|
||||
* - Auditable: the formula above is the entire derivation. Anyone can
|
||||
* reproduce it on any device.
|
||||
*
|
||||
* Security: bounded by the security of the user's Google account. Anyone
|
||||
* who can sign in to the account can recompute every nsec derived from it.
|
||||
* This is the same trade-off any "Google sign-in = identity" scheme makes,
|
||||
* accepted in exchange for not requiring the user to remember a passphrase.
|
||||
*/
|
||||
object GoogleAccountDerivation {
|
||||
private const val DERIVATION_PREFIX = "wisp-account-v1"
|
||||
|
||||
fun deriveAccountKeypair(sub: String, accountIndex: Int): Keys.Keypair {
|
||||
require(sub.isNotEmpty()) { "Google sub claim must not be empty" }
|
||||
require(accountIndex >= 0) { "accountIndex must be non-negative" }
|
||||
val input = "$DERIVATION_PREFIX:$sub:$accountIndex".toByteArray(Charsets.UTF_8)
|
||||
val privkey = MessageDigest.getInstance("SHA-256").digest(input)
|
||||
return Keys.fromPrivkey(privkey)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,20 @@
|
||||
package com.wisp.app.auth
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import com.google.android.gms.auth.api.identity.AuthorizationRequest
|
||||
import com.google.android.gms.auth.api.identity.Identity
|
||||
import com.google.android.gms.common.api.Scope
|
||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Two-step Google sign-in:
|
||||
* 1. Credential Manager returns a GoogleIdTokenCredential whose `id` field is
|
||||
* stable per (Google account, OAuth client). We treat it as the `sub` and
|
||||
* derive the backup encryption key from it.
|
||||
* 2. AuthorizationClient requests an OAuth access token with the
|
||||
* drive.appdata scope. May return a token directly, or a PendingIntent
|
||||
* requiring the user to grant consent the first time.
|
||||
* Returns the user's stable Google identifier (the `sub`-shaped `id` field
|
||||
* from the Google ID token credential). That's the only thing we need — no
|
||||
* OAuth scopes, no Drive access, no consent dialog beyond Credential
|
||||
* Manager's account picker.
|
||||
*/
|
||||
class GoogleSignInManager(
|
||||
context: Context,
|
||||
@@ -35,23 +22,7 @@ class GoogleSignInManager(
|
||||
) {
|
||||
private val credentialManager = CredentialManager.create(context)
|
||||
|
||||
data class GoogleAuthResult(
|
||||
val sub: String,
|
||||
val accessToken: String,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
suspend fun signIn(activity: ComponentActivity): GoogleAuthResult {
|
||||
val sub = getGoogleSubFromCredentialManager(activity)
|
||||
val accessToken = getDriveAccessToken(activity)
|
||||
return GoogleAuthResult(
|
||||
sub = sub,
|
||||
accessToken = accessToken,
|
||||
email = sub.takeIf { it.contains("@") }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getGoogleSubFromCredentialManager(activity: ComponentActivity): String {
|
||||
suspend fun signIn(activity: ComponentActivity): String {
|
||||
val option = GetGoogleIdOption.Builder()
|
||||
.setServerClientId(webClientId)
|
||||
.setFilterByAuthorizedAccounts(false)
|
||||
@@ -82,57 +53,6 @@ class GoogleSignInManager(
|
||||
}
|
||||
return parsed.id
|
||||
}
|
||||
|
||||
private suspend fun getDriveAccessToken(activity: ComponentActivity): String {
|
||||
val authClient = Identity.getAuthorizationClient(activity)
|
||||
val request = AuthorizationRequest.Builder()
|
||||
.setRequestedScopes(listOf(Scope(DRIVE_APPDATA_SCOPE)))
|
||||
.build()
|
||||
|
||||
val authResult = authClient.authorize(request).await()
|
||||
|
||||
if (authResult.hasResolution()) {
|
||||
val pendingIntent = authResult.pendingIntent
|
||||
?: throw GoogleSignInException("Authorization required but no pending intent provided")
|
||||
return resolveAuthorization(activity, pendingIntent)
|
||||
}
|
||||
|
||||
return authResult.accessToken
|
||||
?: throw GoogleSignInException("No access token returned")
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthorization(
|
||||
activity: ComponentActivity,
|
||||
pendingIntent: PendingIntent
|
||||
): String = suspendCoroutine { cont ->
|
||||
val key = "wisp_google_auth_${System.currentTimeMillis()}"
|
||||
var launcher: androidx.activity.result.ActivityResultLauncher<IntentSenderRequest>? = null
|
||||
launcher = activity.activityResultRegistry.register(
|
||||
key,
|
||||
ActivityResultContracts.StartIntentSenderForResult()
|
||||
) { result ->
|
||||
launcher?.unregister()
|
||||
try {
|
||||
val authResult = Identity.getAuthorizationClient(activity)
|
||||
.getAuthorizationResultFromIntent(result.data)
|
||||
val token = authResult.accessToken
|
||||
?: throw GoogleSignInException("Authorization granted but no access token returned")
|
||||
cont.resume(token)
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(
|
||||
if (e is GoogleSignInException) e
|
||||
else GoogleSignInException("Authorization resolution failed: ${e.message}", e)
|
||||
)
|
||||
}
|
||||
}
|
||||
launcher.launch(
|
||||
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DRIVE_APPDATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata"
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleSignInException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
||||
@@ -127,12 +127,12 @@ fun GoogleAuthScreen(
|
||||
when (val s = state) {
|
||||
GoogleAuthViewModel.State.Idle,
|
||||
GoogleAuthViewModel.State.SigningIn,
|
||||
GoogleAuthViewModel.State.CheckingDrive,
|
||||
GoogleAuthViewModel.State.CheckingRelays,
|
||||
GoogleAuthViewModel.State.Working -> {
|
||||
LoadingBlock(
|
||||
label = when (s) {
|
||||
GoogleAuthViewModel.State.SigningIn -> stringResource(R.string.google_auth_signing_in)
|
||||
GoogleAuthViewModel.State.CheckingDrive -> stringResource(R.string.google_auth_checking_drive)
|
||||
GoogleAuthViewModel.State.CheckingRelays -> stringResource(R.string.google_auth_checking_relays)
|
||||
GoogleAuthViewModel.State.Working -> stringResource(R.string.google_auth_working)
|
||||
else -> stringResource(R.string.google_auth_starting)
|
||||
}
|
||||
@@ -140,8 +140,8 @@ fun GoogleAuthScreen(
|
||||
}
|
||||
|
||||
is GoogleAuthViewModel.State.Choose -> ChooseBlock(
|
||||
backups = s.backups,
|
||||
onRestore = { viewModel.restoreAccount(it.fileId) },
|
||||
accounts = s.accounts,
|
||||
onSelect = { viewModel.selectAccount(it.accountIndex) },
|
||||
onCreate = { viewModel.createNewAccount() }
|
||||
)
|
||||
|
||||
@@ -226,14 +226,14 @@ private fun LoadingBlock(label: String) {
|
||||
|
||||
@Composable
|
||||
private fun ChooseBlock(
|
||||
backups: List<GoogleAuthViewModel.BackupSummary>,
|
||||
onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit,
|
||||
accounts: List<GoogleAuthViewModel.AccountSummary>,
|
||||
onSelect: (GoogleAuthViewModel.AccountSummary) -> Unit,
|
||||
onCreate: () -> Unit
|
||||
) {
|
||||
val titleRes = if (backups.isEmpty())
|
||||
val titleRes = if (accounts.isEmpty())
|
||||
R.string.google_auth_choose_title_empty
|
||||
else
|
||||
R.string.google_auth_choose_title_with_backups
|
||||
R.string.google_auth_choose_title_with_accounts
|
||||
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
@@ -243,23 +243,23 @@ private fun ChooseBlock(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (backups.isEmpty()) R.string.google_auth_choose_body_empty
|
||||
else R.string.google_auth_choose_body_with_backups
|
||||
if (accounts.isEmpty()) R.string.google_auth_choose_body_empty
|
||||
else R.string.google_auth_choose_body_with_accounts
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (backups.isNotEmpty()) {
|
||||
if (accounts.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp)
|
||||
) {
|
||||
items(backups, key = { it.npub }) { backup ->
|
||||
BackupRow(backup = backup, onClick = { onRestore(backup) })
|
||||
items(accounts, key = { it.accountIndex }) { account ->
|
||||
AccountRow(account = account, onClick = { onSelect(account) })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ private fun ChooseBlock(
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (backups.isEmpty()) R.string.google_auth_create_first
|
||||
if (accounts.isEmpty()) R.string.google_auth_create_first
|
||||
else R.string.google_auth_create_another
|
||||
)
|
||||
)
|
||||
@@ -283,8 +283,8 @@ private fun ChooseBlock(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupRow(
|
||||
backup: GoogleAuthViewModel.BackupSummary,
|
||||
private fun AccountRow(
|
||||
account: GoogleAuthViewModel.AccountSummary,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -308,10 +308,10 @@ private fun BackupRow(
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
if (!backup.picture.isNullOrBlank()) {
|
||||
if (!account.picture.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(backup.picture)
|
||||
.data(account.picture)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
@@ -323,8 +323,8 @@ private fun BackupRow(
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = backup.displayName?.takeIf { it.isNotBlank() }
|
||||
?: formatShortNpub(backup.npub),
|
||||
text = account.displayName?.takeIf { it.isNotBlank() }
|
||||
?: formatShortPubkey(account.pubkeyHex),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@@ -333,7 +333,7 @@ private fun BackupRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatShortNpub(npub: String): String {
|
||||
if (npub.length <= 18) return npub
|
||||
return npub.take(12) + "…" + npub.takeLast(6)
|
||||
private fun formatShortPubkey(hex: String): String {
|
||||
if (hex.length <= 14) return hex
|
||||
return hex.take(8) + "…" + hex.takeLast(6)
|
||||
}
|
||||
|
||||
@@ -193,10 +193,10 @@ fun SplashScreen(
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1F1F1F)
|
||||
containerColor = Color(0xFF131314),
|
||||
contentColor = Color(0xFFE3E3E3)
|
||||
),
|
||||
border = BorderStroke(1.dp, Color(0xFFDADCE0))
|
||||
border = BorderStroke(1.dp, Color(0xFF8E918F))
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_google_g),
|
||||
|
||||
@@ -5,20 +5,18 @@ import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wisp.app.auth.BackupCrypto
|
||||
import com.wisp.app.auth.DriveBackupService
|
||||
import com.wisp.app.auth.GoogleAccountDerivation
|
||||
import com.wisp.app.auth.GoogleSignInException
|
||||
import com.wisp.app.auth.GoogleSignInManager
|
||||
import com.wisp.app.nostr.Keys
|
||||
import com.wisp.app.nostr.Nip19
|
||||
import com.wisp.app.nostr.toHex
|
||||
import com.wisp.app.repo.FiatPreferences
|
||||
import com.wisp.app.repo.KeyRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -34,26 +32,26 @@ import java.util.concurrent.TimeUnit
|
||||
private const val TAG = "GoogleAuth"
|
||||
|
||||
/**
|
||||
* Orchestrates the "Continue with Google" flow:
|
||||
* 1. Sign in via GoogleSignInManager → derive backup key from sub claim.
|
||||
* 2. List every backup in the user's Drive appDataFolder. Each filename
|
||||
* embeds the npub (`wisp_nsec_<npub>.bin`) so we can show the chooser
|
||||
* without downloading every file.
|
||||
* 3. UI shows a list of restorable accounts (if any) plus a "Create new
|
||||
* account" option that's always available — users can keep adding new
|
||||
* Nostr identities to the same Google account's backup space.
|
||||
* "Continue with Google" flow with deterministic account derivation — no
|
||||
* backup events, no encryption, no Drive.
|
||||
*
|
||||
* The plaintext nsec only leaves Drive when the user actually picks Restore;
|
||||
* generation only happens when they pick Create.
|
||||
* 1. Sign in via Credential Manager → get the user's Google `sub`.
|
||||
* 2. Derive candidate keypairs for indices 0..MAX-1 from that `sub`.
|
||||
* 3. Query a set of public relays for any kind 0 / 3 / 10002 events from
|
||||
* those derived pubkeys. A pubkey with any activity = an "in use"
|
||||
* account that should appear in the chooser.
|
||||
* 4. The chooser shows discovered accounts (avatar + display name come
|
||||
* from the kind-0 metadata, no separate fetch needed) plus "Create
|
||||
* another account" — which derives the next unused index.
|
||||
*
|
||||
* Nothing is published, encrypted, or stored on relays. The user's own
|
||||
* normal Nostr activity is what makes their derived accounts discoverable.
|
||||
*/
|
||||
class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val keyRepo = KeyRepository(app)
|
||||
private val driveService = DriveBackupService()
|
||||
|
||||
/** One restorable account entry surfaced in the chooser. */
|
||||
data class BackupSummary(
|
||||
val fileId: String,
|
||||
val npub: String,
|
||||
data class AccountSummary(
|
||||
val accountIndex: Int,
|
||||
val pubkeyHex: String,
|
||||
val displayName: String? = null,
|
||||
val picture: String? = null
|
||||
@@ -62,8 +60,11 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
sealed class State {
|
||||
object Idle : State()
|
||||
object SigningIn : State()
|
||||
object CheckingDrive : State()
|
||||
data class Choose(val backups: List<BackupSummary>) : State()
|
||||
object CheckingRelays : State()
|
||||
data class Choose(
|
||||
val accounts: List<AccountSummary>,
|
||||
val nextNewIndex: Int
|
||||
) : State()
|
||||
object Working : State()
|
||||
data class Done(val isNewAccount: Boolean) : State()
|
||||
data class Error(val message: String) : State()
|
||||
@@ -72,9 +73,7 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow<State>(State.Idle)
|
||||
val state: StateFlow<State> = _state
|
||||
|
||||
private var pendingBackupKey: ByteArray? = null
|
||||
private var pendingAccessToken: String? = null
|
||||
private var profileFetchJob: Job? = null
|
||||
private var pendingSub: String? = null
|
||||
|
||||
fun beginSignIn(activity: ComponentActivity, webClientId: String) {
|
||||
Log.d(TAG, "beginSignIn called, current state=${_state.value::class.simpleName}, webClientId.length=${webClientId.length}")
|
||||
@@ -88,44 +87,17 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "calling manager.signIn(activity)…")
|
||||
val result = manager.signIn(activity)
|
||||
Log.d(TAG, "signIn returned: sub-len=${result.sub.length}, hasToken=${result.accessToken.isNotEmpty()}")
|
||||
val backupKey = BackupCrypto.deriveBackupKey(result.sub)
|
||||
pendingBackupKey = backupKey
|
||||
pendingAccessToken = result.accessToken
|
||||
val sub = manager.signIn(activity)
|
||||
Log.d(TAG, "signIn returned sub-len=${sub.length}")
|
||||
pendingSub = sub
|
||||
|
||||
_state.value = State.CheckingDrive
|
||||
Log.d(TAG, "state -> CheckingDrive")
|
||||
_state.value = State.CheckingRelays
|
||||
Log.d(TAG, "state -> CheckingRelays")
|
||||
|
||||
val files = driveService.listBackups(result.accessToken)
|
||||
Log.d(TAG, "listBackups returned ${files.size} file(s)")
|
||||
|
||||
val summaries = files.mapNotNull { file ->
|
||||
val npub = file.npubFromName ?: try {
|
||||
// Legacy file with no npub in the filename — decrypt to learn it.
|
||||
val payload = driveService.downloadBackup(result.accessToken, file.fileId)
|
||||
val nsec = BackupCrypto.decryptNsec(payload, backupKey)
|
||||
Nip19.npubEncode(Keys.xOnlyPubkey(nsec))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't resolve npub for ${file.name}; skipping", e)
|
||||
null
|
||||
}
|
||||
npub?.let {
|
||||
val pubkeyHex = try {
|
||||
Nip19.npubDecode(it).toHex()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't decode npub $it", e)
|
||||
return@let null
|
||||
}
|
||||
BackupSummary(fileId = file.fileId, npub = it, pubkeyHex = pubkeyHex)
|
||||
}
|
||||
}.distinctBy { it.npub }
|
||||
|
||||
_state.value = State.Choose(summaries)
|
||||
Log.d(TAG, "state -> Choose with ${summaries.size} restorable account(s)")
|
||||
if (summaries.isNotEmpty()) {
|
||||
fetchProfilesInBackground(summaries.map { it.pubkeyHex })
|
||||
}
|
||||
val accounts = probeForActiveAccounts(sub)
|
||||
val nextNew = accounts.maxOfOrNull { it.accountIndex + 1 } ?: 0
|
||||
_state.value = State.Choose(accounts, nextNew)
|
||||
Log.d(TAG, "state -> Choose with ${accounts.size} active account(s), nextNew=$nextNew")
|
||||
} catch (e: GoogleSignInException) {
|
||||
Log.w(TAG, "GoogleSignInException", e)
|
||||
_state.value = State.Error(e.message ?: "Google sign-in failed.")
|
||||
@@ -136,45 +108,40 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreAccount(fileId: String) {
|
||||
Log.d(TAG, "restoreAccount tapped, fileId=$fileId")
|
||||
val key = pendingBackupKey ?: return
|
||||
val accessToken = pendingAccessToken ?: return
|
||||
fun selectAccount(accountIndex: Int) {
|
||||
Log.d(TAG, "selectAccount tapped, index=$accountIndex")
|
||||
val sub = pendingSub ?: return
|
||||
_state.value = State.Working
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val payload = driveService.downloadBackup(accessToken, fileId)
|
||||
val nsec = BackupCrypto.decryptNsec(payload, key)
|
||||
val keypair = Keys.fromPrivkey(nsec)
|
||||
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
|
||||
keyRepo.saveKeypair(keypair)
|
||||
keyRepo.reloadPrefs(keypair.pubkey.toHex())
|
||||
_state.value = State.Done(isNewAccount = false)
|
||||
Log.d(TAG, "state -> Done(isNewAccount=false)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "restoreAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to restore account.")
|
||||
Log.w(TAG, "selectAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to log in.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewAccount() {
|
||||
Log.d(TAG, "createNewAccount tapped")
|
||||
val key = pendingBackupKey ?: return
|
||||
val accessToken = pendingAccessToken ?: return
|
||||
val sub = pendingSub ?: return
|
||||
val current = _state.value as? State.Choose ?: return
|
||||
val newIndex = current.nextNewIndex
|
||||
_state.value = State.Working
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val keypair = Keys.generate()
|
||||
val npub = Nip19.npubEncode(keypair.pubkey)
|
||||
val payload = BackupCrypto.encryptNsec(keypair.privkey, key)
|
||||
driveService.uploadBackup(accessToken, npub, payload)
|
||||
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, newIndex)
|
||||
keyRepo.saveKeypair(keypair)
|
||||
keyRepo.reloadPrefs(keypair.pubkey.toHex())
|
||||
val fiatPrefs = FiatPreferences.get(getApplication())
|
||||
fiatPrefs.setFiatMode(true)
|
||||
fiatPrefs.setCurrency("USD")
|
||||
_state.value = State.Done(isNewAccount = true)
|
||||
Log.d(TAG, "state -> Done(isNewAccount=true)")
|
||||
Log.d(TAG, "state -> Done(isNewAccount=true), accountIndex=$newIndex")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "createNewAccount failed", e)
|
||||
_state.value = State.Error(e.message ?: "Failed to create account.")
|
||||
@@ -183,114 +150,140 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
profileFetchJob?.cancel()
|
||||
profileFetchJob = null
|
||||
pendingBackupKey = null
|
||||
pendingAccessToken = null
|
||||
pendingSub = null
|
||||
_state.value = State.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens ephemeral WebSocket connections to a couple of widely-used relays,
|
||||
* requests kind-0 profile metadata for each backup's pubkey, and merges the
|
||||
* parsed display name + picture into the Choose state as results arrive.
|
||||
* Cancelled when the user moves past the chooser.
|
||||
* Derives candidate keypairs for indices 0..MAX_INDEX-1, then queries a
|
||||
* set of widely-used relays for any kind 0 / 3 / 10002 events from those
|
||||
* pubkeys. Returns one [AccountSummary] per pubkey that has activity,
|
||||
* with profile data populated when a kind-0 was found in the same probe.
|
||||
*/
|
||||
private fun fetchProfilesInBackground(pubkeyHexList: List<String>) {
|
||||
profileFetchJob?.cancel()
|
||||
profileFetchJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val pubkeys = pubkeyHexList.distinct()
|
||||
if (pubkeys.isEmpty()) return@launch
|
||||
private suspend fun probeForActiveAccounts(sub: String): List<AccountSummary> = withContext(Dispatchers.IO) {
|
||||
data class Candidate(
|
||||
val accountIndex: Int,
|
||||
val pubkeyHex: String,
|
||||
var hasActivity: Boolean = false,
|
||||
var displayName: String? = null,
|
||||
var picture: String? = null
|
||||
)
|
||||
|
||||
val pubkeyJsonArray = pubkeys.joinToString(",") { "\"$it\"" }
|
||||
val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$pubkeyJsonArray]}]"""
|
||||
val candidates = (0 until MAX_INDEX).map { idx ->
|
||||
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, idx)
|
||||
Candidate(accountIndex = idx, pubkeyHex = keypair.pubkey.toHex())
|
||||
}
|
||||
val byPubkey = candidates.associateBy { it.pubkeyHex }
|
||||
|
||||
val sockets = PROFILE_RELAYS.map { url ->
|
||||
try {
|
||||
client.newWebSocket(
|
||||
Request.Builder().url(url).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(reqMessage)
|
||||
}
|
||||
val authorList = candidates.joinToString(",") { "\"${it.pubkeyHex}\"" }
|
||||
val subId = "wisp-google-probe"
|
||||
val req = """["REQ","$subId",{"kinds":[0,3,10002],"authors":[$authorList]}]"""
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleProfileMessage(text)
|
||||
}
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "profile relay $url failed", t)
|
||||
val eoseCount = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val sockets = PROBE_RELAYS.map { url ->
|
||||
try {
|
||||
client.newWebSocket(
|
||||
Request.Builder().url(url).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(req)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val arr = try { json.parseToJsonElement(text) as? JsonArray } catch (_: Exception) { null }
|
||||
?: return
|
||||
if (arr.size < 2) return
|
||||
when (arr[0].jsonPrimitive.content) {
|
||||
"EVENT" -> {
|
||||
if (arr.size < 3 || arr[1].jsonPrimitive.content != subId) return
|
||||
val event = arr[2] as? JsonObject ?: return
|
||||
val pubkey = event["pubkey"]?.jsonPrimitive?.content ?: return
|
||||
val candidate = byPubkey[pubkey] ?: return
|
||||
candidate.hasActivity = true
|
||||
if (event["kind"]?.jsonPrimitive?.content == "0") {
|
||||
val content = event["content"]?.jsonPrimitive?.content
|
||||
if (content != null) {
|
||||
try {
|
||||
val profile = json.parseToJsonElement(content).jsonObject
|
||||
val name = profile["display_name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
?: profile["name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
val picture = profile["picture"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
if (candidate.displayName == null) candidate.displayName = name
|
||||
if (candidate.picture == null) candidate.picture = picture
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"EOSE" -> {
|
||||
if (arr[1].jsonPrimitive.content == subId) {
|
||||
eoseCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't open profile relay $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
kotlinx.coroutines.delay(PROFILE_FETCH_TIMEOUT_MS)
|
||||
} finally {
|
||||
for (socket in sockets.filterNotNull()) {
|
||||
try {
|
||||
socket.send("""["CLOSE","wisp-google-profiles"]""")
|
||||
socket.close(1000, null)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProfileMessage(text: String) {
|
||||
val arr = try { profileJson.parseToJsonElement(text) as? JsonArray } catch (_: Exception) { return } ?: return
|
||||
if (arr.size < 3) return
|
||||
if (arr[0].jsonPrimitive.content != "EVENT") return
|
||||
val event = arr[2] as? JsonObject ?: return
|
||||
if (event["kind"]?.jsonPrimitive?.content != "0") return
|
||||
val pubkey = event["pubkey"]?.jsonPrimitive?.content ?: return
|
||||
val content = event["content"]?.jsonPrimitive?.content ?: return
|
||||
val profile = try { profileJson.parseToJsonElement(content).jsonObject } catch (_: Exception) { return }
|
||||
|
||||
val name = profile["display_name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
?: profile["name"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
val picture = profile["picture"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
|
||||
if (name == null && picture == null) return
|
||||
|
||||
// Merge into current Choose state if still active.
|
||||
val current = _state.value
|
||||
if (current !is State.Choose) return
|
||||
val updated = current.backups.map { backup ->
|
||||
if (backup.pubkeyHex == pubkey && (backup.displayName == null || backup.picture == null)) {
|
||||
backup.copy(
|
||||
displayName = backup.displayName ?: name,
|
||||
picture = backup.picture ?: picture
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "probe relay $url failed", t)
|
||||
eoseCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else backup
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't open probe relay $url", e)
|
||||
eoseCount.incrementAndGet()
|
||||
null
|
||||
}
|
||||
}
|
||||
_state.value = State.Choose(updated)
|
||||
|
||||
val deadline = System.currentTimeMillis() + PROBE_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (eoseCount.get() >= PROBE_RELAYS.size) break
|
||||
delay(100)
|
||||
}
|
||||
|
||||
for (socket in sockets.filterNotNull()) {
|
||||
try {
|
||||
socket.send("""["CLOSE","$subId"]""")
|
||||
socket.close(1000, null)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
|
||||
candidates
|
||||
.filter { it.hasActivity }
|
||||
.map {
|
||||
AccountSummary(
|
||||
accountIndex = it.accountIndex,
|
||||
pubkeyHex = it.pubkeyHex,
|
||||
displayName = it.displayName,
|
||||
picture = it.picture
|
||||
)
|
||||
}
|
||||
.sortedBy { it.accountIndex }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
profileFetchJob?.cancel()
|
||||
pendingBackupKey = null
|
||||
pendingAccessToken = null
|
||||
pendingSub = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val profileJson = Json { ignoreUnknownKeys = true }
|
||||
private val PROFILE_RELAYS = listOf(
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private const val MAX_INDEX = 16
|
||||
private const val PROBE_TIMEOUT_MS = 6_000L
|
||||
private val PROBE_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net"
|
||||
"wss://relay.primal.net",
|
||||
"wss://nos.lol",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.wisp.talk",
|
||||
"wss://relay.ditto.pub"
|
||||
)
|
||||
private const val PROFILE_FETCH_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,19 +47,18 @@
|
||||
<string name="splash_log_in">Log In</string>
|
||||
<string name="splash_continue_with_google">Continue with Google</string>
|
||||
|
||||
<!-- Google Sign-In / Drive Backup -->
|
||||
<!-- Google Sign-In / Nostr Relay Backup -->
|
||||
<!-- Replace this empty string with your OAuth 2.0 Web Client ID from Google Cloud Console. -->
|
||||
<string name="google_web_client_id" translatable="false">410412439051-mhcgeuvc2sjp75v8snucstfr5smrg03g.apps.googleusercontent.com</string>
|
||||
<string name="google_auth_starting">Starting…</string>
|
||||
<string name="google_auth_signing_in">Signing in with Google…</string>
|
||||
<string name="google_auth_checking_drive">Checking your Google Drive backup…</string>
|
||||
<string name="google_auth_checking_relays">Looking for your accounts…</string>
|
||||
<string name="google_auth_working">Almost there…</string>
|
||||
<string name="google_auth_choose_title_with_backups">Your backed-up accounts</string>
|
||||
<string name="google_auth_choose_body_with_backups">Tap an account to restore it, or create a new one. New accounts are encrypted and added to your Google Drive backup.</string>
|
||||
<string name="google_auth_choose_title_with_accounts">Your accounts</string>
|
||||
<string name="google_auth_choose_body_with_accounts">Tap an account to log in, or create a new identity.</string>
|
||||
<string name="google_auth_choose_title_empty">Create your account</string>
|
||||
<string name="google_auth_choose_body_empty">Wisp will generate a new Nostr key and save an encrypted backup to a hidden folder in your Google Drive. The encryption key is derived from your Google account, so anyone with access to it can recover your identity.</string>
|
||||
<string name="google_auth_restore">Restore</string>
|
||||
<string name="google_auth_create_first">Create account & back up</string>
|
||||
<string name="google_auth_choose_body_empty">Wisp derives your Nostr identity from your Google account. Signing in with this Google account on any device produces the same identity. Anyone who can sign in to this Google account can derive — and use — your Nostr keys.</string>
|
||||
<string name="google_auth_create_first">Create account</string>
|
||||
<string name="google_auth_create_another">Create another account</string>
|
||||
<string name="splash_people_online">%d people online now</string>
|
||||
<string name="online_now">Online Now</string>
|
||||
|
||||
@@ -25,7 +25,6 @@ navigation-compose = "2.8.5"
|
||||
lifecycle = "2.8.7"
|
||||
credentials = "1.3.0"
|
||||
googleid = "1.1.1"
|
||||
play-services-auth = "21.3.0"
|
||||
|
||||
[libraries]
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
@@ -72,7 +71,6 @@ breez-sdk-spark = { group = "breez_sdk_spark", name = "bindings-android", versio
|
||||
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
|
||||
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }
|
||||
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
|
||||
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user