Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
7c57561
Add a deleteAccount function that deletes an account and everything u…
cdeutsch Nov 12, 2026
7386109
Ask the linked site for its icon on the web instead of Google
cdeutsch Nov 12, 2026
8d83348
Add a public page for deleting an account, and a menu item that leads…
cdeutsch Nov 12, 2026
abbae1b
Serve the deletion page, the terms and the privacy policy as static H…
cdeutsch Nov 12, 2026
da0a113
Ask the linked site for its icon on Android instead of Google
cdeutsch Nov 12, 2026
95122a9
Add Delete account to the Android menu
cdeutsch Nov 12, 2026
4a81a0c
Add Delete account and site-fetched link icons to the iOS and macOS apps
cdeutsch Nov 12, 2026
35e5061
Give the deletion sweep nine minutes instead of the default one
cdeutsch Nov 12, 2026
f6a282c
Refuse to delete an account with a Stripe customer when billing is off
cdeutsch Nov 12, 2026
6fa646e
Format the account deletion suite with Prettier
cdeutsch Nov 12, 2026
1ccfb83
Let a retried deletion pass a customer Stripe has already deleted
cdeutsch Nov 12, 2026
2b463a5
Keep a deleted account from buying a subscription with its last token
cdeutsch Nov 12, 2026
dfbeb18
Test that the sweep deletes an Auth user the first call left behind
cdeutsch Nov 12, 2026
9fe61c5
Wait for the delete function on the web, and stop saying nothing was …
cdeutsch Nov 12, 2026
eaff0b7
Show "Your account is deleted" once, not for the rest of the tab
cdeutsch Nov 12, 2026
16ca0e2
Name the apps' button as it is labelled on the deletion page
cdeutsch Nov 12, 2026
eb08482
Use the theme's background color for the delete button's text
cdeutsch Nov 12, 2026
38d3c21
Put Delete account above Logout on the web, as in the apps
cdeutsch Nov 12, 2026
c68b77f
Keep a prerendered page's text on screen until the session is known
cdeutsch Nov 12, 2026
0ddfd4f
Stop Change password on Android from saying "Nothing was deleted"
cdeutsch Nov 12, 2026
eb8e797
Wait for the delete function on Android, and stop saying nothing was …
cdeutsch Nov 12, 2026
84f4c26
Bound the wait for Android's notification token to be deleted
cdeutsch Nov 12, 2026
1c3095e
Share one list of legacy passwords between sign-in and deletion on An…
cdeutsch Nov 12, 2026
4c26191
Ask a site for its icon again on Android after a lookup that never re…
cdeutsch Nov 12, 2026
1f866db
Decode a site's icon at the row's size on Android, not its own
cdeutsch Nov 12, 2026
4701aa0
Let an icon lookup finish for an Android row that scrolls off and back
cdeutsch Nov 12, 2026
153582f
Say in the Favicons comment that Glide still caches the images on disk
cdeutsch Nov 12, 2026
c64d064
Mark the one experimental coroutine call in Favicons as opted in
cdeutsch Nov 12, 2026
aa9a642
Wait for the delete function on iOS and macOS, and tell an unknown ou…
cdeutsch Nov 12, 2026
dce897c
Bound the Apple apps' icon cache, and ask again after a lookup that r…
cdeutsch Nov 12, 2026
d62537a
Record what each client clears after a deletion, and what it does wit…
cdeutsch Nov 12, 2026
76dedd4
Cancel an Android row's icon lookup when it leaves the window, and re…
cdeutsch Nov 12, 2026
9db089c
Restore the comment on the Apple apps' sign-out
cdeutsch Nov 12, 2026
bf2fdd0
Recognise an already-deleted account on a retry by asking about the s…
cdeutsch Nov 12, 2026
62d328d
Tell a sign-in that failed from a deletion that could not be confirme…
cdeutsch Nov 12, 2026
0895186
Render a prerendered page on the app's first pass, with the session a…
cdeutsch Nov 12, 2026
21f107a
Draw an Android row's icon only while the row is attached
cdeutsch Nov 12, 2026
4ef2c5c
Keep a failed sign-out from reporting a finished deletion as "not del…
cdeutsch Nov 12, 2026
6762f6f
Note that the web retry path ends the session before it reports the d…
cdeutsch Nov 12, 2026
89a6699
Say "too many tries" on the web when Firebase throttles the sign-in
cdeutsch Nov 12, 2026
6574e08
Merge main, and take its link icons and static pages in place of this…
cdeutsch Nov 12, 2026
09fe4ab
Put the share retry back in the Android release note
cdeutsch Nov 12, 2026
1537168
Give the deletion page's loading line a way out that works without Ja…
cdeutsch Nov 12, 2026
e0895d6
Name the pages' icons among what deleting an account removes
cdeutsch Nov 12, 2026
e3e4457
Say in the deletion doc that decoded link icons stay in memory until …
cdeutsch Nov 12, 2026
eafc287
Rewrap the two paragraphs of the web doc that the merge edited
cdeutsch Nov 12, 2026
08ce3c4
Delete an account's stored files before its push documents
cdeutsch Nov 12, 2026
1c7281f
Name the deletion marker's collection in one place
cdeutsch Nov 12, 2026
21e8ea5
Keep one test for "Stripe no longer has this" in the Stripe client
cdeutsch Nov 12, 2026
890ac89
Tidy two small things in the deletion function's words
cdeutsch Nov 12, 2026
d727fa9
Stop reading a revoked session as a deleted account
cdeutsch Nov 12, 2026
eda0afb
Finish an Android deletion's cleanup even if the screen that asked is…
cdeutsch Nov 12, 2026
8185454
Test how the Android app reads the delete function's answer
cdeutsch Nov 12, 2026
b66a4ba
Stop the unconfirmed message teaching that a refused password means "…
cdeutsch Nov 12, 2026
1f2bcf2
Say "That is not your password" to a signed-in browser
cdeutsch Nov 12, 2026
12782e5
Ask people to keep the Apple apps open while an account is deleted
cdeutsch Nov 12, 2026
8840abd
Make the deletion page's in-app steps match what each client shows
cdeutsch Nov 12, 2026
a04a732
Move `errorText` out of the plan service to a file of its own
cdeutsch Nov 12, 2026
107562a
Drop the Terms page's heading style from the deletion page
cdeutsch Nov 12, 2026
fe80134
Tidy the deletion doc's prose, and note what a bulk delete does to th…
cdeutsch Nov 12, 2026
8fa3ed7
Let the push-delete trigger find its file already gone
cdeutsch Nov 13, 2026
11bb100
Say which Android case the non-cancellable cleanup covers, and which …
cdeutsch Nov 13, 2026
69a75d6
Give the Mac's reason too in the comment on the keep-open hint
cdeutsch Nov 13, 2026
ba1afbd
Record the Android gap and the web's two retry messages in the deleti…
cdeutsch Nov 13, 2026
fb54db7
Import `errorText` from where it lives in the plan pages
cdeutsch Nov 13, 2026
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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,12 @@ npm ci && node --check index.js && npm test
```

`npm run test:rules` and `npm run test:emulator` run against the Firestore and Storage
emulators, through the root `firebase.json`. They need a Java runtime and the Firebase CLI,
and the emulator ports free (8080 and 9199): a run refuses to start otherwise, naming the
port, and one run cannot share another's emulators, so run them one after the other. The
functions' suites deliver signed events to the webhook over HTTP and point
`STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe.
emulators, through the root `firebase.json`; the functions' suites start the Auth emulator
too. They need a Java runtime and the Firebase CLI, and the emulator ports free (8080 and
9199, and 9099 for Auth): a run refuses to start otherwise, naming the port, and one run
cannot share another's emulators, so run them one after the other. The functions' suites
deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for
Stripe's API, so nothing reaches Stripe.

CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key
vectors and builds, the Android unit tests and the encryption reference's tests on every
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package com.makerocketgonow.pushthat

import android.content.Context
import android.util.Log
import com.google.firebase.Firebase
import com.google.firebase.FirebaseException
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
import com.google.firebase.auth.FirebaseAuthInvalidUserException
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.auth
import com.google.firebase.messaging.FirebaseMessaging
import com.makerocketgonow.pushthat.data.AppDatabase
import com.makerocketgonow.pushthat.e2ee.AccountKeys
import com.makerocketgonow.pushthat.e2ee.signOut
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit

/**
* "Delete account" from the list's menu (docs/account-deletion.md at the repository root):
* the password is proved with a fresh sign-in, the `deleteAccount` function deletes the
* account and everything stored under it, and then this install forgets what it held.
*
* The function is called over plain HTTPS as a Firebase callable, as `Plan` calls
* `getPlan`, so no further SDK is needed.
*/
object AccountDeletion {
private const val DELETE_ACCOUNT_URL = "https://us-central1-push-that.cloudfunctions.net/deleteAccount"

/**
* The function said no before deleting anything, in [message]: a sentence it wrote for
* the person (a subscription that could not be cancelled, say).
*/
class RefusedException(message: String) : Exception(message)

/**
* The call ended with no answer from the function (a timeout, a dropped connection, a
* crash on the server) and the account still exists. Nothing can be said about how far
* the deletion got: once the function is past Stripe it has left a marker, and the
* server finishes what it started within about two hours whether or not anyone asks
* again. So the message for this never says that nothing was deleted.
*/
class OutcomeUnknownException(cause: Exception) : Exception(cause)

// A little more than the five minutes the function allows itself (`timeoutSeconds` in
// functions/src/account-deletion.ts), so the function always answers first. It deletes
// an account's pushes five hundred at a time before it answers and sends nothing
// meanwhile; at two minutes this gave up on a large account that the server then went
// on to delete, and told its owner nothing had been removed.
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(310, TimeUnit.SECONDS)
.build()

/** The statuses the function refuses with before it has deleted anything. */
private val REFUSALS = setOf("FAILED_PRECONDITION", "UNAVAILABLE", "UNAUTHENTICATED")

/**
* Deletes the signed-in account. Throws what the sign-in throws for a wrong password
* (`FirebaseAuthInvalidCredentialsException`) or no network, [RefusedException] when
* the function refuses, and [OutcomeUnknownException] when the call ended with no
* answer; in each case nothing on this install has changed and the session is as it
* was.
*
* An account found to be gone already counts as deleted, whichever step finds it: an
* earlier try whose answer was lost has finished on the server, and what is left to do
* is here.
*/
suspend fun delete(context: Context, password: String) {
val app = context.applicationContext
val user = Firebase.auth.currentUser ?: throw IllegalStateException("not signed in")
val uid = user.uid
// The function refuses a sign-in older than five minutes, so the token is one made
// by this proof of the password, not the one the session has been using.
val token = try {
AccountKeys(app).freshTokenFor(password)
} catch (e: FirebaseAuthInvalidUserException) {
// Only "no such user" is an account already deleted. The same exception is
// thrown for a session revoked by a password change and for a disabled
// account, and neither of those has deleted anything.
if (e.errorCode != USER_NOT_FOUND) throw e
forget(app, uid)
return
} catch (e: FirebaseAuthInvalidCredentialsException) {
// With email enumeration protection on, which this project's sign-in assumes
// (AccountKeys), Firebase says "wrong password" for an account that no longer
// exists, never "no such user". So a refused password is checked against the
// account itself, which `reload` answers truthfully whatever that setting is.
if (!isGone(user, revokedMeansGone = false)) throw e
forget(app, uid)
return
}
// From here on, not cancelled with the screen. The call can take five minutes, and
// leaving the screen meanwhile (back, or the activity finishing) clears the view
// model's scope: the request still finished on its thread and the server still
// deleted the account, but the cancellation was rethrown on the way back and
// `forget` never ran, leaving an install signed in to nothing with the deleted
// account's queue and token.
//
// Swiping the app out of Recents is not that: it kills the process, which nothing
// here survives. The session then ends on its own once the revoked token is
// refused, and `forgetSession` drops the key, but the queued pushes and the
// notification token stay until this install is next signed out.
withContext(NonCancellable) { deleteAndForget(app, user, token) }
}

private suspend fun deleteAndForget(app: Context, user: FirebaseUser, token: String) {
val uid = user.uid
try {
callDeleteAccount(token)
} catch (e: IOException) {
// The Auth user is the last thing the function deletes, so gone means done. The
// session was re-authenticated a moment ago, so only the function can have
// revoked it since, and it does that after leaving the marker that gets the
// deletion finished.
if (!isGone(user, revokedMeansGone = true)) throw OutcomeUnknownException(e)
}
forget(app, uid)
}

/**
* Whether the account no longer exists, asked of Firebase. [revokedMeansGone] says
* whether a revoked session counts: it does straight after this deletion's own sign-in,
* and it must not for a session of unknown age, which a password change or reset on
* another device also revokes. Read as "gone" there, a mistyped password signed the
* app out and said the account was deleted when the server had not been asked.
*/
private suspend fun isGone(user: FirebaseUser, revokedMeansGone: Boolean): Boolean = try {
user.reload().await()
false
} catch (e: FirebaseAuthInvalidUserException) {
e.errorCode == USER_NOT_FOUND || (revokedMeansGone && e.errorCode == USER_TOKEN_EXPIRED)
} catch (e: FirebaseException) {
// Could not ask (no network): not known to be gone.
false
}

// Firebase Auth's own codes for `FirebaseAuthInvalidUserException`.
private const val USER_NOT_FOUND = "ERROR_USER_NOT_FOUND"
private const val USER_TOKEN_EXPIRED = "ERROR_USER_TOKEN_EXPIRED"

private suspend fun callDeleteAccount(token: String) = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(DELETE_ACCOUNT_URL)
.header("Authorization", "Bearer $token")
.post("{\"data\":{}}".toRequestBody("application/json".toMediaType()))
.build()
http.newCall(request).execute().use { response ->
when (val answer = answerOf(response.code, response.body.string())) {
Answer.Deleted -> Unit
is Answer.Refused -> throw RefusedException(answer.message)
Answer.Unknown -> throw IOException("deleteAccount answered ${response.code}")
}
}
}

/** What the function's response says happened. */
internal sealed interface Answer {
data object Deleted : Answer

/** Refused before anything was deleted, with the function's sentence for the person. */
class Refused(val message: String) : Answer

/** Nothing can be said about how far the deletion got. */
data object Unknown : Answer
}

/**
* Reads a response's status and body. Apart from the request so that it can be tested:
* this is where "nothing was deleted" and "it may have been" are told apart, and a
* mistake either way tells someone the wrong thing about their account.
*
* Success is a 200 with `{"result": {"deleted": true}}`. A callable's refusal is
* `{"error": {"status", "message"}}`, and the function makes its own before it deletes
* anything, each with a sentence. Any other failure (INTERNAL, a gateway's error page,
* a body that is not JSON) is treated like an answer that never came.
*/
internal fun answerOf(code: Int, body: String?): Answer {
val json = body?.let { runCatching { JSONObject(it) }.getOrNull() }
if (code in 200..299 && json?.optJSONObject("result")?.optBoolean("deleted") == true) return Answer.Deleted
val error = json?.optJSONObject("error") ?: return Answer.Unknown
val message = error.optString("message").takeIf { it.isNotBlank() } ?: return Answer.Unknown
return if (error.optString("status") in REFUSALS) Answer.Refused(message) else Answer.Unknown
}

/**
* What this install held for the account, beyond what any sign-out drops. Each step is
* tried on its own: the account is already gone, so one that fails must not keep the
* rest, or the sign-out, from happening.
*
* - The pushes queued for a retry. Sign-out keeps them, for an account that will sign
* in again; this one never will.
* - The notification token, so Firebase stops addressing this install as that account's
* device. The copy in the user document went with the document.
* - The Analytics data and the installation's Analytics id, so what this install
* reports from here on is not joined to what it reported for the deleted account.
*
* Then the sign-out every button uses, which drops the account key, the last decrypted
* file and the session. The server has deleted the user, so there is nothing to ask of
* Firebase but to forget it here.
*/
private suspend fun forget(app: Context, uid: String) {
withContext(Dispatchers.IO) {
attempt("queued pushes") { AppDatabase.getDatabase(app).pendingPushDao().deleteAllFor(uid) }
// Bounded: this asks Firebase over the network, behind a dialog that cannot be
// dismissed, for an account that is already gone. The token is a courtesy to
// clear; the copy that addressed this device went with the user document.
attempt("notification token") {
withTimeoutOrNull(5_000) { FirebaseMessaging.getInstance().deleteToken().await() }
}
}
attempt("analytics") { FirebaseAnalytics.getInstance(app).resetAnalyticsData() }
signOut(app)
}

private inline fun attempt(what: String, step: () -> Unit) {
try {
step()
} catch (e: Exception) {
Log.w(TAG, "Could not clear the $what after deleting the account", e)
}
}

private const val TAG = "AccountDeletion"
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.FirebaseException
import com.google.firebase.FirebaseNetworkException
import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
import com.makerocketgonow.pushthat.e2ee.AccountKeyStore
import com.makerocketgonow.pushthat.e2ee.AccountKeys
import com.makerocketgonow.pushthat.e2ee.KeysChangedException
Expand All @@ -16,10 +18,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/**
* "Change password" and "Show a new recovery key" from the list's menu. Outside the
* activity for the same reason as `LoginViewModel`: a new recovery key replaces the old
* one's wrap before it is shown, so a rotation that cancelled the step in between would
* leave a key that works and that nobody saw.
* "Change password", "Show a new recovery key" and "Delete account" from the list's menu.
* Outside the activity for the same reason as `LoginViewModel`: a new recovery key replaces
* the old one's wrap before it is shown, so a rotation that cancelled the step in between
* would leave a key that works and that nobody saw. A deletion cancelled between the
* server's answer and the sign-out would leave an install signed in to no account.
*/
class KeySettingsViewModel(application: Application) : AndroidViewModel(application) {
private val keys = AccountKeys(application)
Expand All @@ -29,6 +32,8 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat
class Working(@param:StringRes val message: Int) : State
class RecoveryKey(val recoveryKey: String) : State
class Done(@param:StringRes val message: Int) : State
/** The account is gone and this install is signed out: only the sign-in screen is left. */
data object AccountDeleted : State
class Failed(val message: String) : State
}

Expand All @@ -44,6 +49,31 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat
State.RecoveryKey(keys.replaceRecoveryKey())
}

/**
* Its failures are worded here and not in [messageFor], which all three settings share:
* what is true of a deletion that failed ("nothing was deleted") is not true of a
* password change, and was being said to someone who had only mistyped their current
* password there.
*/
fun deleteAccount(password: String) = run(R.string.deleting_account) {
val app = getApplication<Application>()
try {
AccountDeletion.delete(app, password)
State.AccountDeleted
} catch (e: FirebaseAuthInvalidCredentialsException) {
State.Failed(app.getString(R.string.wrong_password))
} catch (e: AccountDeletion.RefusedException) {
// The function's own sentence, written for the person.
State.Failed(e.message ?: app.getString(R.string.delete_account_unconfirmed))
} catch (e: AccountDeletion.OutcomeUnknownException) {
Log.w(TAG, "The deletion ended without an answer", e)
State.Failed(app.getString(R.string.delete_account_unconfirmed))
} catch (e: FirebaseNetworkException) {
// The sign-in that proves the password never reached Firebase: nothing was asked.
State.Failed(app.getString(R.string.delete_account_offline))
}
}

/** The result has been seen. */
fun acknowledge() {
if (_state.value !is State.Working) _state.value = State.Idle
Expand Down Expand Up @@ -74,6 +104,8 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat
private fun messageFor(e: Exception): String {
val app = getApplication<Application>()
return when (e) {
// Before FirebaseException, whose own message for this is about a "credential".
is FirebaseAuthInvalidCredentialsException -> app.getString(R.string.wrong_password)
// Retrying cannot help: the key this install holds is not the account's.
is KeysChangedException, is AccountKeys.NoAccountKeyException -> app.getString(R.string.keys_changed_here)
// Firebase's own message for its errors: a wrong current password, no network.
Expand Down
Loading
Loading