diff --git a/.gitignore b/.gitignore
index f928a5380..cf483fc1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-*.iml
-.gradle
/local.properties
/.idea/caches
/.idea/libraries
@@ -11,9 +9,7 @@
.DS_Store
/build
/captures
-.externalNativeBuild
.cxx
-local.properties
# Built application files
@@ -36,6 +32,7 @@ out/
# release/
# Gradle files
+.gradle
.gradle/
build/
@@ -48,25 +45,50 @@ proguard/
# Log Files
*.log
-# Android Studio Navigation editor temp files
-.navigation/
-
-# Android Studio captures folder
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
captures/
+.navigation/
+*.ipr
+*~
+*.swp
# IntelliJ
*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
-.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-# Android Studio 3 in .gitignore file.
-.idea/caches
-.idea/modules.xml
-# Comment next line if keeping position of elements in Navigation Editor is relevant for you
-.idea/navEditor.xml
+*.iws
+/out/
+deploymentTargetDropdown.xml
+render.experimental.xml
+
+# User-specific configurations
+.idea/**/caches/
+.idea/**/libraries/
+.idea/**/shelf/
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/.name
+.idea/**/compiler.xml
+.idea/**/copyright/profiles_settings.xml
+.idea/**/encodings.xml
+.idea/**/misc.xml
+.idea/**/modules.xml
+.idea/**/scopes/scope_settings.xml
+.idea/**/dictionaries
+.idea/**/vcs.xml
+.idea/**/jsLibraryMappings.xml
+.idea/**/datasources.xml
+.idea/**/dataSources.ids
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/assetWizardSettings.xml
+.idea/**/gradle.xml
+.idea/**/jarRepositories.xml
+.idea/**/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index fb7f4a8a4..000000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 0477fed22..09cf8b8e4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -80,9 +80,6 @@ dependencies {
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
- // Swipe Refresh
- implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
-
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
@@ -100,9 +97,6 @@ dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
- // Robohash for Avatars
- implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
-
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
@@ -133,29 +127,19 @@ dependencies {
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
- implementation "androidx.camera:camera-camera2:1.2.1"
- implementation 'androidx.camera:camera-lifecycle:1.2.1'
- implementation 'androidx.camera:camera-view:1.2.1'
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
- // For QR Scanning
- implementation 'com.google.mlkit:vision-common:17.3.0'
-
- // Local Barcode Scanning model
- // The idea is to make it work for degoogled phones
- implementation 'com.google.mlkit:barcode-scanning:17.0.3'
-
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
-
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 641da01bb..c22a472ac 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
new file mode 100644
index 000000000..a2c3f5600
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
@@ -0,0 +1,15 @@
+package com.vitorpamplona.amethyst
+
+import android.app.Application
+
+class Amethyst : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ }
+
+ companion object {
+ lateinit var instance: Amethyst
+ private set
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt
index d43669d7c..bfcea8192 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt
@@ -1,20 +1,26 @@
package com.vitorpamplona.amethyst
-import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
- fun preferences(context: Context): EncryptedSharedPreferences {
+ fun prefsFileName(npub: String? = null): String {
+ return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
+ }
+
+ fun preferences(npub: String? = null): EncryptedSharedPreferences {
+ val context = Amethyst.instance
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
+ val preferencesName = prefsFileName(npub)
+
return EncryptedSharedPreferences.create(
context,
- PREFERENCES_NAME,
+ preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
index 83e0f331a..0d60e8885 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst
+import android.annotation.SuppressLint
import android.content.Context
+import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
@@ -9,55 +11,188 @@ import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
+import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.toHex
+import nostr.postr.toNpub
+import java.io.File
import java.util.Locale
-class LocalPreferences(context: Context) {
- private object PrefKeys {
- const val NOSTR_PRIVKEY = "nostr_privkey"
- const val NOSTR_PUBKEY = "nostr_pubkey"
- const val FOLLOWING_CHANNELS = "following_channels"
- const val HIDDEN_USERS = "hidden_users"
- const val RELAYS = "relays"
- const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
- const val LANGUAGE_PREFS = "languagePreferences"
- const val TRANSLATE_TO = "translateTo"
- const val ZAP_AMOUNTS = "zapAmounts"
- const val LATEST_CONTACT_LIST = "latestContactList"
- const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
- val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
- }
+// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
+// To use plaintext SharedPreferences for debugging, set this to true
+// It will only apply in Debug builds
+private const val DEBUG_PLAINTEXT_PREFERENCES = false
+private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
- private val encryptedPreferences = EncryptedStorage.preferences(context)
- private val gson = GsonBuilder().create()
+data class AccountInfo(
+ val npub: String,
+ val hasPrivKey: Boolean,
+ val current: Boolean,
+ val displayName: String?,
+ val profilePicture: String?
+)
- fun clearEncryptedStorage() {
- encryptedPreferences.edit().apply {
- encryptedPreferences.all.keys.forEach { remove(it) }
+private object PrefKeys {
+ const val CURRENT_ACCOUNT = "currently_logged_in_account"
+ const val SAVED_ACCOUNTS = "all_saved_accounts"
+ const val NOSTR_PRIVKEY = "nostr_privkey"
+ const val NOSTR_PUBKEY = "nostr_pubkey"
+ const val DISPLAY_NAME = "display_name"
+ const val PROFILE_PICTURE_URL = "profile_picture"
+ const val FOLLOWING_CHANNELS = "following_channels"
+ const val HIDDEN_USERS = "hidden_users"
+ const val RELAYS = "relays"
+ const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
+ const val LANGUAGE_PREFS = "languagePreferences"
+ const val TRANSLATE_TO = "translateTo"
+ const val ZAP_AMOUNTS = "zapAmounts"
+ const val LATEST_CONTACT_LIST = "latestContactList"
+ const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
+ val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
+}
+
+private val gson = GsonBuilder().create()
+
+object LocalPreferences {
+ private const val comma = ","
+
+ private var currentAccount: String?
+ get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
+ set(npub) {
+ val prefs = encryptedPreferences()
+ prefs.edit().apply {
+ putString(PrefKeys.CURRENT_ACCOUNT, npub)
+ }.apply()
+ }
+
+ private val savedAccounts: List
+ get() = encryptedPreferences()
+ .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
+
+ private val prefsDirPath: String
+ get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
+
+ private fun addAccount(npub: String) {
+ val accounts = savedAccounts.toMutableList()
+ if (npub !in accounts) {
+ accounts.add(npub)
+ }
+ val prefs = encryptedPreferences()
+ prefs.edit().apply {
+ putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma))
}.apply()
}
+ private fun setCurrentAccount(account: Account) {
+ val npub = account.userProfile().pubkeyNpub()
+ currentAccount = npub
+ addAccount(npub)
+ }
+
+ fun switchToAccount(npub: String) {
+ currentAccount = npub
+ }
+
+ /**
+ * Removes the account from the app level shared preferences
+ */
+ private fun removeAccount(npub: String) {
+ val accounts = savedAccounts.toMutableList()
+ if (accounts.remove(npub)) {
+ val prefs = encryptedPreferences()
+ prefs.edit().apply {
+ putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma))
+ }.apply()
+ }
+ }
+
+ /**
+ * Deletes the npub-specific shared preference file
+ */
+ private fun deleteUserPreferenceFile(npub: String) {
+ val prefsDir = File(prefsDirPath)
+ prefsDir.list()?.forEach {
+ if (it.contains(npub)) {
+ File(prefsDir, it).delete()
+ }
+ }
+ }
+
+ private fun encryptedPreferences(npub: String? = null): SharedPreferences {
+ return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
+ val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
+ Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
+ } else {
+ return EncryptedStorage.preferences(npub)
+ }
+ }
+
+ /**
+ * Clears the preferences for a given npub, deletes the preferences xml file,
+ * and switches the user to the first account in the list if it exists
+ *
+ * We need to use `commit()` to write changes to disk and release the file
+ * lock so that it can be deleted. If we use `apply()` there is a race
+ * condition and the file will probably not be deleted
+ */
+ @SuppressLint("ApplySharedPref")
+ fun updatePrefsForLogout(npub: String) {
+ val userPrefs = encryptedPreferences(npub)
+ userPrefs.edit().clear().commit()
+ removeAccount(npub)
+ deleteUserPreferenceFile(npub)
+
+ if (savedAccounts.isEmpty()) {
+ val appPrefs = encryptedPreferences()
+ appPrefs.edit().clear().apply()
+ } else if (currentAccount == npub) {
+ currentAccount = savedAccounts.elementAt(0)
+ }
+ }
+
+ fun updatePrefsForLogin(account: Account) {
+ setCurrentAccount(account)
+ saveToEncryptedStorage(account)
+ }
+
+ fun allSavedAccounts(): List {
+ return savedAccounts.map { npub ->
+ val prefs = encryptedPreferences(npub)
+ val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
+
+ AccountInfo(
+ npub = npub,
+ hasPrivKey = hasPrivKey,
+ current = npub == currentAccount,
+ displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
+ profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
+ )
+ }
+ }
+
fun saveToEncryptedStorage(account: Account) {
- encryptedPreferences.edit().apply {
+ val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
+ prefs.edit().apply {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
- account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
- account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
- account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
- account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
- account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
- account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
- account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
- account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
+ putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
+ putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
+ putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
+ putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
+ putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
+ putString(PrefKeys.TRANSLATE_TO, account.translateTo)
+ putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
+ putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
+ putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
+ putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
- encryptedPreferences.apply {
+ encryptedPreferences(currentAccount).apply {
+ val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
- val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
@@ -75,7 +210,8 @@ class LocalPreferences(context: Context) {
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
- Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
+ Event.gson.fromJson(it, Event::class.java)
+ .getRefinedEvent(true) as ContactListEvent
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -84,43 +220,83 @@ class LocalPreferences(context: Context) {
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
- gson.fromJson(it, object : TypeToken