Merge pull request #534 from barrydeen/fix/google-encrypted-drive-backup

fix(auth): restore encrypted Drive backup; drop deterministic sub derivation
This commit is contained in:
Barry Deen
2026-05-15 15:09:08 -04:00
committed by GitHub
9 changed files with 441 additions and 249 deletions
+1
View File
@@ -117,4 +117,5 @@ dependencies {
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.googleid)
implementation(libs.play.services.auth)
}
+2
View File
@@ -68,3 +68,5 @@
-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.**
@@ -0,0 +1,148 @@
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"
}
}
@@ -1,34 +0,0 @@
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,20 +1,33 @@
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
/**
* 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.
* 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.
*/
class GoogleSignInManager(
context: Context,
@@ -22,7 +35,23 @@ class GoogleSignInManager(
) {
private val credentialManager = CredentialManager.create(context)
suspend fun signIn(activity: ComponentActivity): String {
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 {
val option = GetGoogleIdOption.Builder()
.setServerClientId(webClientId)
.setFilterByAuthorizedAccounts(false)
@@ -53,6 +82,57 @@ 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.CheckingRelays,
GoogleAuthViewModel.State.CheckingDrive,
GoogleAuthViewModel.State.Working -> {
LoadingBlock(
label = when (s) {
GoogleAuthViewModel.State.SigningIn -> stringResource(R.string.google_auth_signing_in)
GoogleAuthViewModel.State.CheckingRelays -> stringResource(R.string.google_auth_checking_relays)
GoogleAuthViewModel.State.CheckingDrive -> stringResource(R.string.google_auth_checking_drive)
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(
accounts = s.accounts,
onSelect = { viewModel.selectAccount(it.accountIndex) },
backups = s.backups,
onRestore = { viewModel.restoreAccount(it.fileId) },
onCreate = { viewModel.createNewAccount() }
)
@@ -226,14 +226,14 @@ private fun LoadingBlock(label: String) {
@Composable
private fun ChooseBlock(
accounts: List<GoogleAuthViewModel.AccountSummary>,
onSelect: (GoogleAuthViewModel.AccountSummary) -> Unit,
backups: List<GoogleAuthViewModel.BackupSummary>,
onRestore: (GoogleAuthViewModel.BackupSummary) -> Unit,
onCreate: () -> Unit
) {
val titleRes = if (accounts.isEmpty())
val titleRes = if (backups.isEmpty())
R.string.google_auth_choose_title_empty
else
R.string.google_auth_choose_title_with_accounts
R.string.google_auth_choose_title_with_backups
Text(
text = stringResource(titleRes),
@@ -243,23 +243,23 @@ private fun ChooseBlock(
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (accounts.isEmpty()) R.string.google_auth_choose_body_empty
else R.string.google_auth_choose_body_with_accounts
if (backups.isEmpty()) R.string.google_auth_choose_body_empty
else R.string.google_auth_choose_body_with_backups
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (accounts.isNotEmpty()) {
if (backups.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
) {
items(accounts, key = { it.accountIndex }) { account ->
AccountRow(account = account, onClick = { onSelect(account) })
items(backups, key = { it.npub }) { backup ->
BackupRow(backup = backup, onClick = { onRestore(backup) })
Spacer(Modifier.height(8.dp))
}
}
@@ -275,7 +275,7 @@ private fun ChooseBlock(
) {
Text(
stringResource(
if (accounts.isEmpty()) R.string.google_auth_create_first
if (backups.isEmpty()) R.string.google_auth_create_first
else R.string.google_auth_create_another
)
)
@@ -283,8 +283,8 @@ private fun ChooseBlock(
}
@Composable
private fun AccountRow(
account: GoogleAuthViewModel.AccountSummary,
private fun BackupRow(
backup: GoogleAuthViewModel.BackupSummary,
onClick: () -> Unit
) {
val context = LocalContext.current
@@ -308,10 +308,10 @@ private fun AccountRow(
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
) {
if (!account.picture.isNullOrBlank()) {
if (!backup.picture.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(account.picture)
.data(backup.picture)
.crossfade(true)
.build(),
contentDescription = null,
@@ -323,8 +323,8 @@ private fun AccountRow(
Spacer(Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.displayName?.takeIf { it.isNotBlank() }
?: formatShortPubkey(account.pubkeyHex),
text = backup.displayName?.takeIf { it.isNotBlank() }
?: formatShortNpub(backup.npub),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
@@ -333,7 +333,7 @@ private fun AccountRow(
}
}
private fun formatShortPubkey(hex: String): String {
if (hex.length <= 14) return hex
return hex.take(8) + "" + hex.takeLast(6)
private fun formatShortNpub(npub: String): String {
if (npub.length <= 18) return npub
return npub.take(12) + "" + npub.takeLast(6)
}
@@ -5,18 +5,20 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wisp.app.auth.GoogleAccountDerivation
import com.wisp.app.auth.BackupCrypto
import com.wisp.app.auth.DriveBackupService
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.delay
import kotlinx.coroutines.Job
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
@@ -32,26 +34,26 @@ import java.util.concurrent.TimeUnit
private const val TAG = "GoogleAuth"
/**
* "Continue with Google" flow with deterministic account derivation no
* backup events, no encryption, no Drive.
* 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.
*
* 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.
* The plaintext nsec only leaves Drive when the user actually picks Restore;
* generation only happens when they pick Create.
*/
class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
private val keyRepo = KeyRepository(app)
private val driveService = DriveBackupService()
data class AccountSummary(
val accountIndex: Int,
/** One restorable account entry surfaced in the chooser. */
data class BackupSummary(
val fileId: String,
val npub: String,
val pubkeyHex: String,
val displayName: String? = null,
val picture: String? = null
@@ -60,11 +62,8 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
sealed class State {
object Idle : State()
object SigningIn : State()
object CheckingRelays : State()
data class Choose(
val accounts: List<AccountSummary>,
val nextNewIndex: Int
) : State()
object CheckingDrive : State()
data class Choose(val backups: List<BackupSummary>) : State()
object Working : State()
data class Done(val isNewAccount: Boolean) : State()
data class Error(val message: String) : State()
@@ -73,7 +72,9 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = _state
private var pendingSub: String? = null
private var pendingBackupKey: ByteArray? = null
private var pendingAccessToken: String? = null
private var profileFetchJob: Job? = null
fun beginSignIn(activity: ComponentActivity, webClientId: String) {
Log.d(TAG, "beginSignIn called, current state=${_state.value::class.simpleName}, webClientId.length=${webClientId.length}")
@@ -87,21 +88,43 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
try {
Log.d(TAG, "calling manager.signIn(activity)…")
val sub = manager.signIn(activity)
Log.d(TAG, "signIn returned sub-len=${sub.length}")
pendingSub = sub
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
_state.value = State.CheckingRelays
Log.d(TAG, "state -> CheckingRelays")
_state.value = State.CheckingDrive
Log.d(TAG, "state -> CheckingDrive")
val accounts = probeForActiveAccounts(sub)
if (accounts.isEmpty()) {
Log.d(TAG, "no accounts found — auto-creating account at index 0")
createAccountAt(sub, accountIndex = 0)
} else {
val nextNew = accounts.maxOf { it.accountIndex } + 1
_state.value = State.Choose(accounts, nextNew)
Log.d(TAG, "state -> Choose with ${accounts.size} active account(s), nextNew=$nextNew")
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 })
}
} catch (e: GoogleSignInException) {
Log.w(TAG, "GoogleSignInException", e)
@@ -113,192 +136,161 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun selectAccount(accountIndex: Int) {
Log.d(TAG, "selectAccount tapped, index=$accountIndex")
val sub = pendingSub ?: return
fun restoreAccount(fileId: String) {
Log.d(TAG, "restoreAccount tapped, fileId=$fileId")
val key = pendingBackupKey ?: return
val accessToken = pendingAccessToken ?: return
_state.value = State.Working
viewModelScope.launch {
try {
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
val payload = driveService.downloadBackup(accessToken, fileId)
val nsec = BackupCrypto.decryptNsec(payload, key)
val keypair = Keys.fromPrivkey(nsec)
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, "selectAccount failed", e)
_state.value = State.Error(e.message ?: "Failed to log in.")
Log.w(TAG, "restoreAccount failed", e)
_state.value = State.Error(e.message ?: "Failed to restore account.")
}
}
}
fun createNewAccount() {
Log.d(TAG, "createNewAccount tapped")
val sub = pendingSub ?: return
val current = _state.value as? State.Choose ?: return
val newIndex = current.nextNewIndex
viewModelScope.launch {
createAccountAt(sub, newIndex)
}
}
/**
* Shared helper: derives the keypair at [accountIndex], saves it locally,
* and transitions to Done(isNewAccount = true). Called both from the user
* tapping "Create another account" and from the auto-create-at-zero path
* when no existing accounts are discovered on sign-in.
*/
private suspend fun createAccountAt(sub: String, accountIndex: Int) {
val key = pendingBackupKey ?: return
val accessToken = pendingAccessToken ?: return
_state.value = State.Working
try {
val keypair = GoogleAccountDerivation.deriveAccountKeypair(sub, accountIndex)
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), accountIndex=$accountIndex")
} catch (e: Exception) {
Log.w(TAG, "createAccountAt failed", e)
_state.value = State.Error(e.message ?: "Failed to create account.")
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)
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)")
} catch (e: Exception) {
Log.w(TAG, "createNewAccount failed", e)
_state.value = State.Error(e.message ?: "Failed to create account.")
}
}
}
fun reset() {
pendingSub = null
profileFetchJob?.cancel()
profileFetchJob = null
pendingBackupKey = null
pendingAccessToken = null
_state.value = State.Idle
}
/**
* 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.
* 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.
*/
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
)
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
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 pubkeyJsonArray = pubkeys.joinToString(",") { "\"$it\"" }
val reqMessage = """["REQ","wisp-google-profiles",{"kinds":[0],"authors":[$pubkeyJsonArray]}]"""
val authorList = candidates.joinToString(",") { "\"${it.pubkeyHex}\"" }
val subId = "wisp-google-probe"
val req = """["REQ","$subId",{"kinds":[0,3,10002],"authors":[$authorList]}]"""
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 client = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
override fun onMessage(webSocket: WebSocket, text: String) {
handleProfileMessage(text)
}
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()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "profile relay $url failed", t)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "probe relay $url failed", t)
eoseCount.incrementAndGet()
}
}
)
} catch (e: Exception) {
Log.w(TAG, "couldn't open probe relay $url", e)
eoseCount.incrementAndGet()
null
)
} catch (e: Exception) {
Log.w(TAG, "couldn't open profile relay $url", e)
null
}
}
}
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
)
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()
}
.sortedBy { it.accountIndex }
}
}
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
)
} else backup
}
_state.value = State.Choose(updated)
}
override fun onCleared() {
super.onCleared()
pendingSub = null
profileFetchJob?.cancel()
pendingBackupKey = null
pendingAccessToken = null
}
companion object {
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(
private val profileJson = Json { ignoreUnknownKeys = true }
private val PROFILE_RELAYS = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.wisp.talk",
"wss://relay.ditto.pub"
"wss://relay.primal.net"
)
private const val PROFILE_FETCH_TIMEOUT_MS = 8_000L
}
}
+7 -6
View File
@@ -47,18 +47,19 @@
<string name="splash_log_in">Log In</string>
<string name="splash_continue_with_google">Continue with Google</string>
<!-- Google Sign-In / Nostr Relay Backup -->
<!-- Google Sign-In / Drive 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_relays">Looking for your accounts</string>
<string name="google_auth_checking_drive">Checking your Google Drive backup</string>
<string name="google_auth_working">Almost there…</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_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_empty">Create your account</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_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 &amp; back up</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>
+2
View File
@@ -25,6 +25,7 @@ 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" }
@@ -71,6 +72,7 @@ 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" }