diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..a167f938
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,127 @@
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+/.idea/androidTestResultsUserPreferences.xml
+/.idea/deploymentTargetDropDown.xml
+.DS_Store
+/build
+/captures
+.cxx
+
+
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+captures/
+.navigation/
+*.ipr
+*~
+*.swp
+
+# IntelliJ
+*.iml
+*.iws
+/out/
+deploymentTargetDropdown.xml
+render.experimental.xml
+
+# User-specific configurations
+.idea/**/caches/
+.idea/**/libraries/
+.idea/**/shelf/
+.idea/**/codeStyles
+.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.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..44ca2d9b
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 00000000..e1eea1d6
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..6f99da97
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,102 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
+}
+
+android {
+ namespace 'com.greenart7c3.nostrsigner'
+ compileSdk 33
+
+ defaultConfig {
+ applicationId "com.greenart7c3.nostrsigner"
+ minSdk 26
+ targetSdk 33
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary true
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.3.2'
+ }
+ packagingOptions {
+ resources {
+ excludes += '/META-INF/{AL2.0,LGPL2.1}'
+ }
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.10.1'
+ implementation 'androidx.activity:activity-compose:1.7.2'
+ implementation "androidx.compose.ui:ui:$compose_ui_version"
+ implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
+
+ // Needs this to open gallery / image upload
+ implementation "androidx.fragment:fragment-ktx:$fragment_version"
+
+ // Navigation
+ implementation("androidx.navigation:navigation-compose:$nav_version")
+
+ // Observe Live data as State
+ implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
+
+ implementation "androidx.compose.material:material:$compose_ui_version"
+ implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
+
+
+ implementation "androidx.compose.material:material-icons-extended:1.4.3"
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+
+ // Bitcoin secp256k1 bindings to Android
+ implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
+
+ implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
+
+ // Lifecycle
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
+
+ // For QR generation
+ implementation 'com.google.zxing:core:3.5.1'
+ implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
+
+ // Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
+ implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+
+ // Websockets API
+ implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/greenart7c3/nostrsigner/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/greenart7c3/nostrsigner/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..462da017
--- /dev/null
+++ b/app/src/androidTest/java/com/greenart7c3/nostrsigner/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.greenart7c3.nostrsigner
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import junit.framework.TestCase.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.greenart7c3.nostrsigner", appContext.packageName)
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..5c821c1c
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/EncryptedStorage.kt b/app/src/main/java/com/greenart7c3/nostrsigner/EncryptedStorage.kt
new file mode 100644
index 00000000..d926384c
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/EncryptedStorage.kt
@@ -0,0 +1,29 @@
+package com.greenart7c3.nostrsigner
+
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+object EncryptedStorage {
+ private const val PREFERENCES_NAME = "secret_keeper"
+
+ fun prefsFileName(npub: String? = null): String {
+ return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
+ }
+
+ fun preferences(npub: String? = null): EncryptedSharedPreferences {
+ val context = nostrsigner.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,
+ preferencesName,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ ) as EncryptedSharedPreferences
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt
new file mode 100644
index 00000000..aea077c1
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt
@@ -0,0 +1,171 @@
+package com.greenart7c3.nostrsigner
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.SharedPreferences
+import com.greenart7c3.nostrsigner.models.Account
+import com.greenart7c3.nostrsigner.models.hexToByteArray
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.KeyPair
+import com.greenart7c3.nostrsigner.service.toNpub
+import fr.acinq.secp256k1.jni.BuildConfig
+import java.io.File
+
+// 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 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"
+}
+
+object LocalPreferences {
+ private const val comma = ","
+
+ private var currentAccount: String? = null
+
+ fun currentAccount(): String? {
+ if (currentAccount == null) {
+ currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
+ }
+ return currentAccount
+ }
+
+ private fun updateCurrentAccount(npub: String) {
+ if (currentAccount != npub) {
+ currentAccount = npub
+
+ encryptedPreferences().edit().apply {
+ putString(PrefKeys.CURRENT_ACCOUNT, npub)
+ }.apply()
+ }
+ }
+
+ private var savedAccounts: List? = null
+
+ private fun savedAccounts(): List {
+ if (savedAccounts == null) {
+ savedAccounts = encryptedPreferences()
+ .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
+ }
+ return savedAccounts!!
+ }
+
+ private fun updateSavedAccounts(accounts: List) {
+ if (savedAccounts != accounts) {
+ savedAccounts = accounts
+
+ encryptedPreferences().edit().apply {
+ putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
+ }.apply()
+ }
+ }
+
+ private val prefsDirPath: String
+ get() = "${nostrsigner.instance.filesDir.parent}/shared_prefs/"
+
+ private fun addAccount(npub: String) {
+ val accounts = savedAccounts().toMutableList()
+ if (npub !in accounts) {
+ accounts.add(npub)
+ updateSavedAccounts(accounts)
+ }
+ }
+
+ private fun setCurrentAccount(account: Account) {
+ val npub = account.keyPair.pubKey.toNpub()
+ updateCurrentAccount(npub)
+ addAccount(npub)
+ }
+
+ fun switchToAccount(npub: String) {
+ updateCurrentAccount(npub)
+ }
+
+ /**
+ * Removes the account from the app level shared preferences
+ */
+ private fun removeAccount(npub: String) {
+ val accounts = savedAccounts().toMutableList()
+ if (accounts.remove(npub)) {
+ updateSavedAccounts(accounts)
+ }
+ }
+
+ /**
+ * 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"
+ nostrsigner.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) {
+ updateCurrentAccount(savedAccounts().elementAt(0))
+ }
+ }
+
+ fun updatePrefsForLogin(account: Account) {
+ setCurrentAccount(account)
+ saveToEncryptedStorage(account)
+ }
+
+ fun saveToEncryptedStorage(account: Account) {
+ val prefs = encryptedPreferences(account.keyPair.pubKey.toNpub())
+ prefs.edit().apply {
+ account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
+ account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
+ }.apply()
+ }
+
+ fun loadFromEncryptedStorage(): Account? {
+ return loadFromEncryptedStorage(currentAccount())
+ }
+
+ private fun loadFromEncryptedStorage(npub: String?): Account? {
+ encryptedPreferences(npub).apply {
+ val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
+ val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
+
+ return Account(
+ keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
new file mode 100644
index 00000000..51a5dea6
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/MainActivity.kt
@@ -0,0 +1,41 @@
+package com.greenart7c3.nostrsigner
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.greenart7c3.nostrsigner.models.IntentData
+import com.greenart7c3.nostrsigner.ui.AccountScreen
+import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
+import com.greenart7c3.nostrsigner.ui.theme.NostrSignerTheme
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ var event: IntentData? = null
+ if (intent.data != null) {
+ println(intent?.extras?.getString("relays"))
+ val list = intent?.extras?.getString("relays")!!.split(",")
+ event = IntentData(intent?.data?.toString() ?: "", list)
+ }
+
+ NostrSignerTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colors.background
+ ) {
+ val accountStateViewModel: AccountStateViewModel = viewModel {
+ AccountStateViewModel()
+ }
+ AccountScreen(accountStateViewModel, event, this@MainActivity)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/PubKeyFormatter.kt b/app/src/main/java/com/greenart7c3/nostrsigner/PubKeyFormatter.kt
new file mode 100644
index 00000000..6c2de19f
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/PubKeyFormatter.kt
@@ -0,0 +1,6 @@
+package com.greenart7c3.nostrsigner
+
+fun String.toShortenHex(): String {
+ if (length <= 16) return this
+ return replaceRange(8, length - 8, ":")
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt
new file mode 100644
index 00000000..4537b6fd
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt
@@ -0,0 +1,18 @@
+package com.greenart7c3.nostrsigner.models
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.LiveData
+import com.greenart7c3.nostrsigner.service.KeyPair
+
+@Stable
+class Account(
+ val keyPair: KeyPair
+) {
+ val saveable: AccountLiveData = AccountLiveData(this)
+}
+
+class AccountLiveData(account: Account) : LiveData(AccountState(account))
+
+@Immutable
+class AccountState(val account: Account)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/HexUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/HexUtils.kt
new file mode 100644
index 00000000..623a8cba
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/HexUtils.kt
@@ -0,0 +1,52 @@
+package com.greenart7c3.nostrsigner.models
+
+import com.greenart7c3.nostrsigner.service.KeyPair
+import com.greenart7c3.nostrsigner.toShortenHex
+import com.greenart7c3.nostrsigner.service.bechToBytes
+import com.greenart7c3.nostrsigner.service.nip19.Nip19
+import fr.acinq.secp256k1.Hex
+
+/** Makes the distinction between String and Hex **/
+typealias HexKey = String
+
+fun ByteArray.toHexKey(): HexKey {
+ return Hex.encode(this)
+}
+
+fun HexKey.hexToByteArray(): ByteArray {
+ return Hex.decode(this)
+}
+
+fun HexKey.toDisplayHexKey(): String {
+ return this.toShortenHex()
+}
+
+fun decodePublicKey(key: String): ByteArray {
+ val parsed = Nip19.uriToRoute(key)
+ val pubKeyParsed = parsed?.hex?.hexToByteArray()
+
+ return if (key.startsWith("nsec")) {
+ KeyPair(privKey = key.bechToBytes()).pubKey
+ } else if (pubKeyParsed != null) {
+ pubKeyParsed
+ } else {
+ Hex.decode(key)
+ }
+}
+
+fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
+ return try {
+ val parsed = Nip19.uriToRoute(key)
+ val pubKeyParsed = parsed?.hex
+
+ if (key.startsWith("nsec")) {
+ KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey()
+ } else if (pubKeyParsed != null) {
+ pubKeyParsed
+ } else {
+ Hex.decode(key).toHexKey()
+ }
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt
new file mode 100644
index 00000000..d3bacbec
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt
@@ -0,0 +1,6 @@
+package com.greenart7c3.nostrsigner.models
+
+class IntentData(
+ val data: String,
+ val relays: List
+)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/ListUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/ListUtils.kt
new file mode 100644
index 00000000..2dd8a6d8
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/ListUtils.kt
@@ -0,0 +1,10 @@
+package com.greenart7c3.nostrsigner.models
+
+import androidx.compose.runtime.Stable
+
+@Stable
+data class ImmutableListOfLists(val lists: List> = emptyList())
+
+fun List>.toImmutableListOfLists(): ImmutableListOfLists {
+ return ImmutableListOfLists(this)
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/TimeUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/TimeUtils.kt
new file mode 100644
index 00000000..38914338
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/TimeUtils.kt
@@ -0,0 +1,13 @@
+package com.greenart7c3.nostrsigner.models
+
+object TimeUtils {
+ const val fiveMinutes = 60 * 5
+ const val oneHour = 60 * 60
+ const val oneDay = 24 * 60 * 60
+
+ fun now() = System.currentTimeMillis() / 1000
+ fun fiveMinutesAgo() = now() - fiveMinutes
+ fun oneHourAgo() = now() - oneHour
+ fun oneDayAgo() = now() - oneDay
+ fun eightHoursAgo() = now() - (oneHour * 8)
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/nostrsigner.kt b/app/src/main/java/com/greenart7c3/nostrsigner/nostrsigner.kt
new file mode 100644
index 00000000..8c5b963d
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/nostrsigner.kt
@@ -0,0 +1,15 @@
+package com.greenart7c3.nostrsigner
+
+import android.app.Application
+
+class nostrsigner : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ }
+
+ companion object {
+ lateinit var instance: nostrsigner
+ private set
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/Bech32Util.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/Bech32Util.kt
new file mode 100644
index 00000000..9e35b3a7
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/Bech32Util.kt
@@ -0,0 +1,217 @@
+package com.greenart7c3.nostrsigner.service
+
+/*
+ * Copyright 2020 ACINQ SAS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import kotlin.jvm.JvmStatic
+
+/**
+ * Bech32 works with 5 bits values, we use this type to make it explicit: whenever you see Int5 it means 5 bits values,
+ * and whenever you see Byte it means 8 bits values.
+ */
+private typealias Int5 = Byte
+
+/**
+ * Bech32 and Bech32m address formats.
+ * See https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki.
+ */
+object Bech32 {
+ const val alphabet: String = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+
+ enum class Encoding(public val constant: Int) {
+ Bech32(1),
+ Bech32m(0x2bc830a3),
+ Beck32WithoutChecksum(0)
+ }
+
+ // char -> 5 bits value
+ private val map = Array(255) { -1 }
+
+ init {
+ for (i in 0..alphabet.lastIndex) {
+ map[alphabet[i].code] = i.toByte()
+ }
+ }
+
+ private fun expand(hrp: String): Array {
+ val result = Array(hrp.length + 1 + hrp.length) { 0 }
+ for (i in hrp.indices) {
+ result[i] = hrp[i].code.shr(5).toByte()
+ result[hrp.length + 1 + i] = (hrp[i].code and 31).toByte()
+ }
+ result[hrp.length] = 0
+ return result
+ }
+
+ private fun polymod(values: Array, values1: Array): Int {
+ val GEN = arrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
+ var chk = 1
+ values.forEach { v ->
+ val b = chk shr 25
+ chk = ((chk and 0x1ffffff) shl 5) xor v.toInt()
+ for (i in 0..5) {
+ if (((b shr i) and 1) != 0) chk = chk xor GEN[i]
+ }
+ }
+ values1.forEach { v ->
+ val b = chk shr 25
+ chk = ((chk and 0x1ffffff) shl 5) xor v.toInt()
+ for (i in 0..5) {
+ if (((b shr i) and 1) != 0) chk = chk xor GEN[i]
+ }
+ }
+ return chk
+ }
+
+ /**
+ * @param hrp human readable prefix
+ * @param int5s 5-bit data
+ * @param encoding encoding to use (bech32 or bech32m)
+ * @return hrp + data encoded as a Bech32 string
+ */
+ @JvmStatic
+ public fun encode(hrp: String, int5s: Array, encoding: Encoding): String {
+ require(hrp.lowercase() == hrp || hrp.uppercase() == hrp) { "mixed case strings are not valid bech32 prefixes" }
+ val data = int5s.toByteArray().toTypedArray()
+ val checksum = when (encoding) {
+ Encoding.Beck32WithoutChecksum -> arrayOf()
+ else -> checksum(hrp, data, encoding)
+ }
+ return hrp + "1" + (data + checksum).map { i -> alphabet[i.toInt()] }.toCharArray().concatToString()
+ }
+
+ /**
+ * @param hrp human readable prefix
+ * @param data data to encode
+ * @param encoding encoding to use (bech32 or bech32m)
+ * @return hrp + data encoded as a Bech32 string
+ */
+ @JvmStatic
+ public fun encodeBytes(hrp: String, data: ByteArray, encoding: Encoding): String = encode(hrp, eight2five(data), encoding)
+
+ /**
+ * decodes a bech32 string
+ * @param bech32 bech32 string
+ * @param noChecksum if true, the bech32 string doesn't have a checksum
+ * @return a (hrp, data, encoding) tuple
+ */
+ @JvmStatic
+ public fun decode(bech32: String, noChecksum: Boolean = false): Triple, Encoding> {
+ require(bech32.lowercase() == bech32 || bech32.uppercase() == bech32) { "mixed case strings are not valid bech32" }
+ bech32.forEach { require(it.code in 33..126) { "invalid character " } }
+ val input = bech32.lowercase()
+ val pos = input.lastIndexOf('1')
+ val hrp = input.take(pos)
+ require(hrp.length in 1..83) { "hrp must contain 1 to 83 characters" }
+ val data = Array(input.length - pos - 1) { 0 }
+ for (i in 0..data.lastIndex) data[i] = map[input[pos + 1 + i].code]
+ return if (noChecksum) {
+ Triple(hrp, data, Encoding.Beck32WithoutChecksum)
+ } else {
+ val encoding = when (polymod(expand(hrp), data)) {
+ Encoding.Bech32.constant -> Encoding.Bech32
+ Encoding.Bech32m.constant -> Encoding.Bech32m
+ else -> throw IllegalArgumentException("invalid checksum for $bech32")
+ }
+ Triple(hrp, data.dropLast(6).toTypedArray(), encoding)
+ }
+ }
+
+ /**
+ * decodes a bech32 string
+ * @param bech32 bech32 string
+ * @param noChecksum if true, the bech32 string doesn't have a checksum
+ * @return a (hrp, data, encoding) tuple
+ */
+ @JvmStatic
+ public fun decodeBytes(bech32: String, noChecksum: Boolean = false): Triple {
+ val (hrp, int5s, encoding) = decode(bech32, noChecksum)
+ return Triple(hrp, five2eight(int5s, 0), encoding)
+ }
+
+ /**
+ * @param hrp Human Readable Part
+ * @param data data (a sequence of 5 bits integers)
+ * @param encoding encoding to use (bech32 or bech32m)
+ * @return a checksum computed over hrp and data
+ */
+ private fun checksum(hrp: String, data: Array, encoding: Encoding): Array {
+ val values = expand(hrp) + data
+ val poly = polymod(values, arrayOf(0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0.toByte())) xor encoding.constant
+ return Array(6) { i -> (poly.shr(5 * (5 - i)) and 31).toByte() }
+ }
+
+ /**
+ * @param input a sequence of 8 bits integers
+ * @return a sequence of 5 bits integers
+ */
+ @JvmStatic
+ public fun eight2five(input: ByteArray): Array {
+ var buffer = 0L
+ val output = ArrayList()
+ var count = 0
+ input.forEach { b ->
+ buffer = (buffer shl 8) or (b.toLong() and 0xff)
+ count += 8
+ while (count >= 5) {
+ output.add(((buffer shr (count - 5)) and 31).toByte())
+ count -= 5
+ }
+ }
+ if (count > 0) output.add(((buffer shl (5 - count)) and 31).toByte())
+ return output.toTypedArray()
+ }
+
+ /**
+ * @param input a sequence of 5 bits integers
+ * @return a sequence of 8 bits integers
+ */
+ @JvmStatic
+ public fun five2eight(input: Array, offset: Int): ByteArray {
+ var buffer = 0L
+ val output = ArrayList()
+ var count = 0
+ for (i in offset..input.lastIndex) {
+ val b = input[i]
+ buffer = (buffer shl 5) or (b.toLong() and 31)
+ count += 5
+ while (count >= 8) {
+ output.add(((buffer shr (count - 8)) and 0xff).toByte())
+ count -= 8
+ }
+ }
+ require(count <= 4) { "Zero-padding of more than 4 bits" }
+ require((buffer and ((1L shl count) - 1L)) == 0L) { "Non-zero padding in 8-to-5 conversion" }
+ return output.toByteArray()
+ }
+}
+
+fun ByteArray.toNsec() = Bech32.encodeBytes(hrp = "nsec", this, Bech32.Encoding.Bech32)
+fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32)
+fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
+fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32)
+fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32)
+fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32)
+
+fun String.bechToBytes(hrp: String? = null): ByteArray {
+ val decodedForm = Bech32.decodeBytes(this)
+ hrp?.also {
+ if (it != decodedForm.first) {
+ throw IllegalArgumentException("Expected $it but obtained ${decodedForm.first}")
+ }
+ }
+ return decodedForm.second
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/CryptoUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/CryptoUtils.kt
new file mode 100644
index 00000000..37f8f9eb
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/CryptoUtils.kt
@@ -0,0 +1,87 @@
+package com.greenart7c3.nostrsigner.service
+
+import fr.acinq.secp256k1.Hex
+import fr.acinq.secp256k1.Secp256k1
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+object CryptoUtils {
+ private val secp256k1 = Secp256k1.get()
+ private val random = SecureRandom()
+
+ /**
+ * Provides a 32B "private key" aka random number
+ */
+ fun privkeyCreate(): ByteArray {
+ val bytes = ByteArray(32)
+ random.nextBytes(bytes)
+ return bytes
+ }
+
+ fun pubkeyCreate(privKey: ByteArray) =
+ secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(privKey)).copyOfRange(1, 33)
+
+ fun sign(data: ByteArray, privKey: ByteArray): ByteArray =
+ secp256k1.signSchnorr(data, privKey, null)
+
+ fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
+ val sharedSecret = getSharedSecret(privateKey, pubKey)
+ return encrypt(msg, sharedSecret)
+ }
+
+ fun encrypt(msg: String, sharedSecret: ByteArray): String {
+ val iv = ByteArray(16)
+ random.nextBytes(iv)
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
+ val ivBase64 = Base64.getEncoder().encodeToString(iv)
+ val encryptedMsg = cipher.doFinal(msg.toByteArray())
+ val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
+ return "$encryptedMsgBase64?iv=$ivBase64"
+ }
+
+ fun decrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
+ val sharedSecret = getSharedSecret(privateKey, pubKey)
+ return decrypt(msg, sharedSecret)
+ }
+
+ fun decrypt(msg: String, sharedSecret: ByteArray): String {
+ val parts = msg.split("?iv=")
+ val iv = parts[1].run { Base64.getDecoder().decode(this) }
+ val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
+ return String(cipher.doFinal(encryptedMsg))
+ }
+
+ fun verifySignature(
+ signature: ByteArray,
+ hash: ByteArray,
+ pubKey: ByteArray
+ ): Boolean {
+ return secp256k1.verifySchnorr(signature, hash, pubKey)
+ }
+
+ fun sha256(data: ByteArray): ByteArray {
+ // Creates a new buffer every time
+ return MessageDigest.getInstance("SHA-256").digest(data)
+ }
+
+ /**
+ * @return 32B shared secret
+ */
+ fun getSharedSecret(privateKey: ByteArray, pubKey: ByteArray): ByteArray =
+ secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33)
+}
+
+fun Int.toByteArray(): ByteArray {
+ val bytes = ByteArray(4)
+ (0..3).forEach {
+ bytes[3 - it] = ((this ushr (8 * it)) and 0xFFFF).toByte()
+ }
+ return bytes
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/KeyPair.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/KeyPair.kt
new file mode 100644
index 00000000..bb3361dd
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/KeyPair.kt
@@ -0,0 +1,34 @@
+package com.greenart7c3.nostrsigner.service
+
+import com.greenart7c3.nostrsigner.models.toHexKey
+
+class KeyPair(
+ privKey: ByteArray? = null,
+ pubKey: ByteArray? = null
+) {
+ val privKey: ByteArray?
+ val pubKey: ByteArray
+
+ init {
+ if (privKey == null) {
+ if (pubKey == null) {
+ // create new, random keys
+ this.privKey = CryptoUtils.privkeyCreate()
+ this.pubKey = CryptoUtils.pubkeyCreate(this.privKey)
+ } else {
+ // this is a read-only account
+ check(pubKey.size == 32)
+ this.privKey = null
+ this.pubKey = pubKey
+ }
+ } else {
+ // as private key is provided, ignore the public key and set keys according to private key
+ this.privKey = privKey
+ this.pubKey = CryptoUtils.pubkeyCreate(privKey)
+ }
+ }
+
+ override fun toString(): String {
+ return "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}"
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/ATag.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/ATag.kt
new file mode 100644
index 00000000..64b943e6
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/ATag.kt
@@ -0,0 +1,83 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import android.util.Log
+import androidx.compose.runtime.Immutable
+import com.greenart7c3.nostrsigner.models.hexToByteArray
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.bechToBytes
+import com.greenart7c3.nostrsigner.service.nip19.Tlv
+import com.greenart7c3.nostrsigner.service.toByteArray
+import com.greenart7c3.nostrsigner.service.toNAddress
+import fr.acinq.secp256k1.Hex
+
+@Immutable
+data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
+ fun toTag() = "$kind:$pubKeyHex:$dTag"
+
+ fun toNAddr(): String {
+ val kind = kind.toByteArray()
+ val author = pubKeyHex.hexToByteArray()
+ val dTag = dTag.toByteArray(Charsets.UTF_8)
+ val relay = relay?.toByteArray(Charsets.UTF_8)
+
+ var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, dTag.size.toByte()) + dTag
+
+ if (relay != null) {
+ fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
+ }
+
+ fullArray = fullArray +
+ byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author +
+ byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
+
+ return fullArray.toNAddress()
+ }
+
+ companion object {
+ fun isATag(key: String): Boolean {
+ return key.startsWith("naddr1") || key.contains(":")
+ }
+
+ fun parse(address: String, relay: String?): ATag? {
+ return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) {
+ parseNAddr(address)
+ } else {
+ parseAtag(address, relay)
+ }
+ }
+
+ fun parseAtag(atag: String, relay: String?): ATag? {
+ return try {
+ val parts = atag.split(":")
+ Hex.decode(parts[1])
+ ATag(parts[0].toInt(), parts[1], parts[2], relay)
+ } catch (t: Throwable) {
+ Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}")
+ null
+ }
+ }
+
+ fun parseNAddr(naddr: String): ATag? {
+ try {
+ val key = naddr.removePrefix("nostr:")
+
+ if (key.startsWith("naddr")) {
+ val tlv = Tlv.parse(key.bechToBytes())
+ val d = tlv.get(Tlv.Type.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
+ val relay = tlv.get(Tlv.Type.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
+ val author = tlv.get(Tlv.Type.AUTHOR.id)?.get(0)?.toHexKey()
+ val kind = tlv.get(Tlv.Type.KIND.id)?.get(0)?.let { Tlv.toInt32(it) }
+
+ if (kind != null && author != null) {
+ return ATag(kind, author, d, relay)
+ }
+ }
+ } catch (e: Throwable) {
+ Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}")
+ // e.printStackTrace()
+ }
+
+ return null
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EmojiPackEvent.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EmojiPackEvent.kt
new file mode 100644
index 00000000..61d7752c
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EmojiPackEvent.kt
@@ -0,0 +1,56 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import androidx.compose.runtime.Immutable
+import com.greenart7c3.nostrsigner.models.HexKey
+import com.greenart7c3.nostrsigner.models.TimeUtils
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.CryptoUtils
+
+@Immutable
+class EmojiPackEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ companion object {
+ const val kind = 30030
+
+ fun create(
+ name: String = "",
+ privateKey: ByteArray,
+ createdAt: Long = TimeUtils.now()
+ ): EmojiPackEvent {
+ val content = ""
+ val pubKey = CryptoUtils.pubkeyCreate(privateKey)
+
+ val tags = mutableListOf>()
+ tags.add(listOf("d", name))
+
+ val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
+ val sig = CryptoUtils.sign(id, privateKey)
+ return EmojiPackEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
+
+@Immutable
+data class EmojiUrl(val code: String, val url: String) {
+ fun encode(): String {
+ return ":$code:$url"
+ }
+
+ companion object {
+ fun decode(encodedEmojiSetup: String): EmojiUrl? {
+ val emojiParts = encodedEmojiSetup.split(":", limit = 3)
+ return if (emojiParts.size > 2) {
+ EmojiUrl(emojiParts[1], emojiParts[2])
+ } else {
+ null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/Event.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/Event.kt
new file mode 100644
index 00000000..5a9e75d9
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/Event.kt
@@ -0,0 +1,324 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import android.util.Log
+import androidx.compose.runtime.Immutable
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonArray
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+import com.google.gson.annotations.SerializedName
+import com.greenart7c3.nostrsigner.models.HexKey
+import com.greenart7c3.nostrsigner.models.TimeUtils
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.CryptoUtils
+import com.greenart7c3.nostrsigner.service.nip19.Nip19
+import fr.acinq.secp256k1.Hex
+import java.lang.reflect.Type
+import java.math.BigDecimal
+import java.util.*
+
+@Immutable
+open class Event(
+ val id: HexKey,
+ @SerializedName("pubkey") val pubKey: HexKey,
+ @SerializedName("created_at") val createdAt: Long,
+ val kind: Int,
+ val tags: List>,
+ val content: String,
+ val sig: HexKey
+) : EventInterface {
+ override fun id(): HexKey = id
+
+ override fun pubKey(): HexKey = pubKey
+
+ override fun createdAt(): Long = createdAt
+
+ override fun kind(): Int = kind
+
+ override fun tags(): List> = tags
+
+ override fun content(): String = content
+
+ override fun sig(): HexKey = sig
+
+ override fun toJson(): String = gson.toJson(this)
+
+ fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
+
+ override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
+ override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
+
+ override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
+
+ override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
+
+ override fun isSensitive() = tags.any {
+ (it.size > 0 && it[0].equals("content-warning", true)) ||
+ (it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
+ (it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
+ }
+
+ override fun zapraiserAmount() = tags.firstOrNull() {
+ (it.size > 1 && it[0] == "zapraiser")
+ }?.get(1)?.toLongOrNull()
+
+ override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
+
+ override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
+ val aTagValue = it[1]
+ val relay = it.getOrNull(2)
+
+ ATag.parse(aTagValue, relay)
+ }
+
+ override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
+ override fun geohashes() = tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] }
+
+ override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
+
+ override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
+
+ override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex }
+
+ override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
+
+ override fun isTaggedAddressableNotes(idHexes: Set) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes }
+
+ override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
+
+ override fun isTaggedGeoHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "g" && it[1].startsWith(hashtag, true) }
+ override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
+ override fun isTaggedGeoHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "g" && it[1].lowercase() in hashtags }
+ override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
+
+ override fun firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
+
+ override fun isTaggedAddressableKind(kind: Int): Boolean {
+ val kindStr = kind.toString()
+ return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
+ }
+
+ override fun getTagOfAddressableKind(kind: Int): ATag? {
+ val kindStr = kind.toString()
+ val aTag = tags
+ .firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
+ ?.getOrNull(1)
+ ?: return null
+
+ return ATag.parse(aTag, null)
+ }
+
+ override fun getPoWRank(): Int {
+ var rank = 0
+ for (i in 0..id.length) {
+ if (id[i] == '0') {
+ rank += 4
+ } else if (id[i] in '4'..'7') {
+ rank += 1
+ break
+ } else if (id[i] in '2'..'3') {
+ rank += 2
+ break
+ } else if (id[i] == '1') {
+ rank += 3
+ break
+ } else {
+ break
+ }
+ }
+ return rank
+ }
+
+ override fun getGeoHash(): String? {
+ return tags.firstOrNull { it.size > 1 && it[0] == "g" }?.get(1)?.ifBlank { null }
+ }
+
+ override fun getReward(): BigDecimal? {
+ return try {
+ tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ open fun toNIP19(): String {
+ return if (this is AddressableEvent) {
+ ATag(kind, pubKey, dTag(), null).toNAddr()
+ } else {
+ Nip19.createNEvent(id, pubKey, kind, null)
+ }
+ }
+
+ fun toNostrUri(): String {
+ return "nostr:${toNIP19()}"
+ }
+
+ /**
+ * Checks if the ID is correct and then if the pubKey's secret key signed the event.
+ */
+ override fun checkSignature() {
+ if (!id.contentEquals(generateId())) {
+ throw Exception(
+ """|Unexpected ID.
+ | Event: ${toJson()}
+ | Actual ID: $id
+ | Generated: ${generateId()}
+ """.trimIndent()
+ )
+ }
+ if (!CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
+ throw Exception("""Bad signature!""")
+ }
+ }
+
+ override fun hasValidSignature(): Boolean {
+ return try {
+ id.contentEquals(generateId()) && CryptoUtils.verifySignature(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
+ } catch (e: Exception) {
+ Log.e("Event", "Fail checking if event $id has a valid signature", e)
+ false
+ }
+ }
+
+ private fun generateId(): String {
+ val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
+
+ // GSON decided to hardcode these replacements.
+ // They break Nostr's hash check.
+ // These lines revert their code.
+ // https://github.com/google/gson/issues/2295
+ val rawEventJson = gson.toJson(rawEvent)
+ .replace("\\u2028", "\u2028")
+ .replace("\\u2029", "\u2029")
+
+ return CryptoUtils.sha256(rawEventJson.toByteArray()).toHexKey()
+ }
+
+ private class EventDeserializer : JsonDeserializer {
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type?,
+ context: JsonDeserializationContext?
+ ): Event {
+ val jsonObject = json.asJsonObject
+ return Event(
+ id = jsonObject.get("id").asString,
+ pubKey = jsonObject.get("pubkey").asString,
+ createdAt = jsonObject.get("created_at").asLong,
+ kind = jsonObject.get("kind").asInt,
+ tags = jsonObject.get("tags").asJsonArray.map {
+ it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
+ },
+ content = jsonObject.get("content").asString,
+ sig = jsonObject.get("sig").asString
+ )
+ }
+ }
+
+ private class EventSerializer : JsonSerializer {
+ override fun serialize(
+ src: Event,
+ typeOfSrc: Type?,
+ context: JsonSerializationContext?
+ ): JsonElement {
+ return JsonObject().apply {
+ addProperty("id", src.id)
+ addProperty("pubkey", src.pubKey)
+ addProperty("created_at", src.createdAt)
+ addProperty("kind", src.kind)
+ add(
+ "tags",
+ JsonArray().also { jsonTags ->
+ src.tags.forEach { tag ->
+ jsonTags.add(
+ JsonArray().also { jsonTagElement ->
+ tag.forEach { tagElement ->
+ jsonTagElement.add(tagElement)
+ }
+ }
+ )
+ }
+ }
+ )
+ addProperty("content", src.content)
+ addProperty("sig", src.sig)
+ }
+ }
+ }
+
+ private class ByteArrayDeserializer : JsonDeserializer {
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type?,
+ context: JsonDeserializationContext?
+ ): ByteArray = Hex.decode(json.asString)
+ }
+
+ private class ByteArraySerializer : JsonSerializer {
+ override fun serialize(
+ src: ByteArray,
+ typeOfSrc: Type?,
+ context: JsonSerializationContext?
+ ) = JsonPrimitive(src.toHexKey())
+ }
+
+ companion object {
+ val gson: Gson = GsonBuilder()
+ .disableHtmlEscaping()
+ .registerTypeAdapter(Event::class.java, EventSerializer())
+ .registerTypeAdapter(Event::class.java, EventDeserializer())
+ .registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
+ .registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
+ .registerTypeAdapter(Response::class.java, ResponseDeserializer())
+ .registerTypeAdapter(Request::class.java, RequestDeserializer())
+ .create()
+
+ fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
+
+ fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
+
+ fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
+ else -> this
+ }
+
+ fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List>, content: String): ByteArray {
+ val rawEvent = listOf(
+ 0,
+ pubKey,
+ createdAt,
+ kind,
+ tags,
+ content
+ )
+
+ // GSON decided to hardcode these replacements.
+ // They break Nostr's hash check.
+ // These lines revert their code.
+ // https://github.com/google/gson/issues/2295
+ val rawEventJson = gson.toJson(rawEvent)
+ .replace("\\u2028", "\u2028")
+ .replace("\\u2029", "\u2029")
+
+ return CryptoUtils.sha256(rawEventJson.toByteArray())
+ }
+
+ fun create(privateKey: ByteArray, kind: Int, tags: List> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
+ val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = CryptoUtils.sign(id, privateKey).toHexKey()
+ return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
+ }
+ }
+}
+
+@Immutable
+interface AddressableEvent {
+ fun dTag(): String
+ fun address(): ATag
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EventInterface.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EventInterface.kt
new file mode 100644
index 00000000..09d8dee7
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/EventInterface.kt
@@ -0,0 +1,66 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import androidx.compose.runtime.Immutable
+import com.greenart7c3.nostrsigner.models.HexKey
+import java.math.BigDecimal
+
+@Immutable
+interface EventInterface {
+ fun id(): HexKey
+
+ fun pubKey(): HexKey
+
+ fun createdAt(): Long
+
+ fun kind(): Int
+
+ fun tags(): List>
+
+ fun content(): String
+
+ fun sig(): HexKey
+
+ fun toJson(): String
+
+ fun checkSignature()
+
+ fun hasValidSignature(): Boolean
+
+ fun isTaggedUser(idHex: String): Boolean
+
+ fun isTaggedEvent(idHex: String): Boolean
+
+ fun isTaggedAddressableNote(idHex: String): Boolean
+ fun isTaggedAddressableNotes(idHexes: Set): Boolean
+
+ fun isTaggedHash(hashtag: String): Boolean
+ fun isTaggedGeoHash(hashtag: String): Boolean
+
+ fun isTaggedHashes(hashtags: Set): Boolean
+ fun isTaggedGeoHashes(hashtags: Set): Boolean
+
+ fun firstIsTaggedHashes(hashtags: Set): String?
+ fun firstIsTaggedAddressableNote(addressableNotes: Set): String?
+
+ fun isTaggedAddressableKind(kind: Int): Boolean
+ fun getTagOfAddressableKind(kind: Int): ATag?
+
+ fun hashtags(): List
+ fun geohashes(): List
+
+ fun getReward(): BigDecimal?
+ fun getPoWRank(): Int
+ fun getGeoHash(): String?
+
+ fun zapAddress(): String?
+ fun isSensitive(): Boolean
+ fun zapraiserAmount(): Long?
+
+ fun taggedAddresses(): List
+ fun taggedUsers(): List
+ fun taggedEvents(): List
+ fun taggedUrls(): List
+
+ fun taggedEmojis(): List
+ fun matchTag1With(text: String): Boolean
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/GeneralListEvent.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/GeneralListEvent.kt
new file mode 100644
index 00000000..e2cde7aa
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/GeneralListEvent.kt
@@ -0,0 +1,112 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import android.util.Log
+import androidx.compose.runtime.Immutable
+import com.google.gson.reflect.TypeToken
+import com.greenart7c3.nostrsigner.models.HexKey
+import com.greenart7c3.nostrsigner.models.hexToByteArray
+import com.greenart7c3.nostrsigner.service.CryptoUtils
+
+@Immutable
+abstract class GeneralListEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ kind: Int,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
+ override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
+ override fun address() = ATag(kind, pubKey, dTag(), null)
+
+ fun category() = dTag()
+ fun bookmarkedPosts() = taggedEvents()
+ fun bookmarkedPeople() = taggedUsers()
+
+ fun plainContent(privKey: ByteArray): String? {
+ if (content.isBlank()) return null
+
+ return try {
+ val sharedSecret = CryptoUtils.getSharedSecret(privKey, pubKey.hexToByteArray())
+
+ return CryptoUtils.decrypt(content, sharedSecret)
+ } catch (e: Exception) {
+ Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
+ null
+ }
+ }
+
+ @Transient
+ private var privateTagsCache: List>? = null
+
+ fun privateTags(privKey: ByteArray): List>? {
+ if (privateTagsCache != null) {
+ return privateTagsCache
+ }
+
+ privateTagsCache = try {
+ gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type)
+ } catch (e: Throwable) {
+ Log.w("GeneralList", "Error parsing the JSON ${e.message}")
+ null
+ }
+ return privateTagsCache
+ }
+
+ fun privateTagsOrEmpty(privKey: ByteArray): List> {
+ return privateTags(privKey) ?: emptyList()
+ }
+
+ fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
+
+ fun privateHashtags(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }
+ fun privateGeohashes(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "g" }?.map { it[1] }
+ fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
+ fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
+ val aTagValue = it.getOrNull(1)
+ val relay = it.getOrNull(2)
+
+ if (aTagValue != null) ATag.parse(aTagValue, relay) else null
+ }
+
+ companion object {
+ fun createPrivateTags(
+ privEvents: List? = null,
+ privUsers: List? = null,
+ privAddresses: List? = null,
+
+ privateKey: ByteArray,
+ pubKey: ByteArray
+ ): String {
+ val privTags = mutableListOf>()
+ privEvents?.forEach {
+ privTags.add(listOf("e", it))
+ }
+ privUsers?.forEach {
+ privTags.add(listOf("p", it))
+ }
+ privAddresses?.forEach {
+ privTags.add(listOf("a", it.toTag()))
+ }
+ val msg = gson.toJson(privTags)
+
+ return CryptoUtils.encrypt(
+ msg,
+ privateKey,
+ pubKey
+ )
+ }
+
+ fun encryptTags(
+ privateTags: List>? = null,
+ privateKey: ByteArray
+ ): String {
+ return CryptoUtils.encrypt(
+ msg = gson.toJson(privateTags),
+ privateKey = privateKey,
+ pubKey = CryptoUtils.pubkeyCreate(privateKey)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentRequestEvent.kt
new file mode 100644
index 00000000..75926199
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentRequestEvent.kt
@@ -0,0 +1,45 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonParseException
+import java.lang.reflect.Type
+
+
+// REQUEST OBJECTS
+
+abstract class Request(var method: String? = null)
+
+// PayInvoice Call
+class PayInvoiceParams(var invoice: String? = null)
+
+class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
+
+ companion object {
+ fun create(bolt11: String): PayInvoiceMethod {
+ return PayInvoiceMethod(PayInvoiceParams(bolt11))
+ }
+ }
+}
+
+class RequestDeserializer :
+ JsonDeserializer {
+ @Throws(JsonParseException::class)
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type,
+ context: JsonDeserializationContext
+ ): Request? {
+ val jsonObject = json.asJsonObject
+ val method = jsonObject.get("method")?.asString
+
+ if (method == "pay_invoice") {
+ return context.deserialize(jsonObject, PayInvoiceMethod::class.java)
+ }
+ return null
+ }
+
+ companion object {
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentResponseEvent.kt
new file mode 100644
index 00000000..f9b24591
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/LnZapPaymentResponseEvent.kt
@@ -0,0 +1,81 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonParseException
+import com.google.gson.annotations.SerializedName
+import java.lang.reflect.Type
+
+// RESPONSE OBJECTS
+abstract class Response(
+ @SerializedName("result_type")
+ val resultType: String
+)
+
+// PayInvoice Call
+
+class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
+ Response("pay_invoice") {
+ class PayInvoiceResultParams(val preimage: String)
+}
+
+class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
+ Response("pay_invoice") {
+ class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
+
+ enum class ErrorType {
+ @SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
+ RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
+ @SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
+ NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
+ @SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
+ INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
+ @SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
+ QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
+ @SerializedName(value = "restricted", alternate = ["RESTRICTED"])
+ RESTRICTED, // This public key is not allowed to do this operation.
+ @SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
+ UNAUTHORIZED, // This public key has no wallet connected.
+ @SerializedName(value = "internal", alternate = ["INTERNAL"])
+ INTERNAL, // An internal error.
+ @SerializedName(value = "other", alternate = ["OTHER"])
+ OTHER // Other error.
+ }
+}
+
+class ResponseDeserializer :
+ JsonDeserializer {
+ @Throws(JsonParseException::class)
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type,
+ context: JsonDeserializationContext
+ ): Response? {
+ val jsonObject = json.asJsonObject
+ val resultType = jsonObject.get("result_type")?.asString
+
+ if (resultType == "pay_invoice") {
+ val result = jsonObject.get("result")?.asJsonObject
+ val error = jsonObject.get("error")?.asJsonObject
+ if (result != null) {
+ return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java)
+ }
+ if (error != null) {
+ return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java)
+ }
+ } else {
+ // tries to guess
+ if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
+ return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java)
+ }
+ if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
+ return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java)
+ }
+ }
+ return null
+ }
+
+ companion object {
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/model/RelayAuthEvent.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/RelayAuthEvent.kt
new file mode 100644
index 00000000..49c9dd32
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/model/RelayAuthEvent.kt
@@ -0,0 +1,36 @@
+package com.greenart7c3.nostrsigner.service.model
+
+import androidx.compose.runtime.Immutable
+import com.greenart7c3.nostrsigner.models.HexKey
+import com.greenart7c3.nostrsigner.models.TimeUtils
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.CryptoUtils
+
+@Immutable
+class RelayAuthEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+ fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
+ fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
+
+ companion object {
+ const val kind = 22242
+
+ fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
+ val content = ""
+ val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
+ val tags = listOf(
+ listOf("relay", relay),
+ listOf("challenge", challenge)
+ )
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = CryptoUtils.sign(id, privateKey)
+ return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Nip19.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Nip19.kt
new file mode 100644
index 00000000..dea4e80a
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Nip19.kt
@@ -0,0 +1,172 @@
+package com.greenart7c3.nostrsigner.service.nip19
+
+import android.util.Log
+import androidx.compose.runtime.Immutable
+import com.greenart7c3.nostrsigner.models.hexToByteArray
+import com.greenart7c3.nostrsigner.models.toHexKey
+import com.greenart7c3.nostrsigner.service.bechToBytes
+import com.greenart7c3.nostrsigner.service.toByteArray
+import com.greenart7c3.nostrsigner.service.toNEvent
+import java.util.regex.Pattern
+
+object Nip19 {
+ enum class Type {
+ USER, NOTE, EVENT, RELAY, ADDRESS
+ }
+
+ val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
+
+ @Immutable
+ data class Return(
+ val type: Type,
+ val hex: String,
+ val relay: String? = null,
+ val author: String? = null,
+ val kind: Int? = null,
+ val additionalChars: String = ""
+ )
+
+ fun uriToRoute(uri: String?): Return? {
+ if (uri == null) return null
+
+ try {
+ val matcher = nip19regex.matcher(uri)
+ if (!matcher.find()) {
+ return null
+ }
+
+ val uriScheme = matcher.group(1) // nostr:
+ val type = matcher.group(2) // npub1
+ val key = matcher.group(3) // bech32
+ val additionalChars = matcher.group(4) // additional chars
+
+ return parseComponents(uriScheme, type, key, additionalChars)
+ } catch (e: Throwable) {
+ Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
+ }
+
+ return null
+ }
+
+ fun parseComponents(
+ uriScheme: String?,
+ type: String,
+ key: String?,
+ additionalChars: String?
+ ): Return? {
+ return try {
+ val bytes = (type + key).bechToBytes()
+ val parsed = when (type.lowercase()) {
+ "npub1" -> npub(bytes)
+ "note1" -> note(bytes)
+ "nprofile1" -> nprofile(bytes)
+ "nevent1" -> nevent(bytes)
+ "nrelay1" -> nrelay(bytes)
+ "naddr1" -> naddr(bytes)
+ else -> null
+ }
+ parsed?.copy(additionalChars = additionalChars ?: "")
+ } catch (e: Throwable) {
+ Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
+ null
+ }
+ }
+
+ private fun npub(bytes: ByteArray): Return {
+ return Return(Type.USER, bytes.toHexKey())
+ }
+
+ private fun note(bytes: ByteArray): Return {
+ return Return(Type.NOTE, bytes.toHexKey())
+ }
+
+ private fun nprofile(bytes: ByteArray): Return? {
+ val tlv = Tlv.parse(bytes)
+
+ val hex = tlv.get(Tlv.Type.SPECIAL.id)
+ ?.get(0)
+ ?.toHexKey() ?: return null
+
+ val relay = tlv.get(Tlv.Type.RELAY.id)
+ ?.get(0)
+ ?.toString(Charsets.UTF_8)
+
+ return Return(Type.USER, hex, relay)
+ }
+
+ private fun nevent(bytes: ByteArray): Return? {
+ val tlv = Tlv.parse(bytes)
+
+ val hex = tlv.get(Tlv.Type.SPECIAL.id)
+ ?.get(0)
+ ?.toHexKey() ?: return null
+
+ val relay = tlv.get(Tlv.Type.RELAY.id)
+ ?.get(0)
+ ?.toString(Charsets.UTF_8)
+
+ val author = tlv.get(Tlv.Type.AUTHOR.id)
+ ?.get(0)
+ ?.toHexKey()
+
+ val kind = tlv.get(Tlv.Type.KIND.id)
+ ?.get(0)
+ ?.let { Tlv.toInt32(it) }
+
+ return Return(Type.EVENT, hex, relay, author, kind)
+ }
+
+ private fun nrelay(bytes: ByteArray): Return? {
+ val relayUrl = Tlv.parse(bytes)
+ .get(Tlv.Type.SPECIAL.id)
+ ?.get(0)
+ ?.toString(Charsets.UTF_8) ?: return null
+
+ return Return(Type.RELAY, relayUrl)
+ }
+
+ private fun naddr(bytes: ByteArray): Return? {
+ val tlv = Tlv.parse(bytes)
+
+ val d = tlv.get(Tlv.Type.SPECIAL.id)
+ ?.get(0)
+ ?.toString(Charsets.UTF_8) ?: return null
+
+ val relay = tlv.get(Tlv.Type.RELAY.id)
+ ?.get(0)
+ ?.toString(Charsets.UTF_8)
+
+ val author = tlv.get(Tlv.Type.AUTHOR.id)
+ ?.get(0)
+ ?.toHexKey()
+
+ val kind = tlv.get(Tlv.Type.KIND.id)
+ ?.get(0)
+ ?.let { Tlv.toInt32(it) }
+
+ return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
+ }
+
+ public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
+ val kind = kind?.toByteArray()
+ val author = author?.hexToByteArray()
+ val idHex = idHex.hexToByteArray()
+ val relay = relay?.toByteArray(Charsets.UTF_8)
+
+ var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
+
+ if (relay != null) {
+ fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
+ }
+
+ if (author != null) {
+ fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
+ }
+
+ if (kind != null) {
+ fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
+ }
+
+ return fullArray.toNEvent()
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Tlv.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Tlv.kt
new file mode 100644
index 00000000..9ca8f017
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip19/Tlv.kt
@@ -0,0 +1,36 @@
+package com.greenart7c3.nostrsigner.service.nip19
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+object Tlv {
+ enum class Type(val id: Byte) {
+ SPECIAL(0),
+ RELAY(1),
+ AUTHOR(2),
+ KIND(3);
+ }
+
+ fun toInt32(bytes: ByteArray): Int {
+ require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
+ return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
+ }
+
+ fun parse(data: ByteArray): Map> {
+ val result = mutableMapOf>()
+ var rest = data
+ while (rest.isNotEmpty()) {
+ val t = rest[0]
+ val l = rest[1]
+ val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
+ rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
+ if (v.size < l) continue
+
+ if (!result.containsKey(t)) {
+ result[t] = mutableListOf()
+ }
+ result[t]?.add(v)
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/EOSE.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/EOSE.kt
new file mode 100644
index 00000000..b32263ac
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/EOSE.kt
@@ -0,0 +1,9 @@
+package com.greenart7c3.nostrsigner.service.relays
+
+class EOSETime(var time: Long) {
+ override fun toString(): String {
+ return time.toString()
+ }
+}
+
+
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/JsonFilter.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/JsonFilter.kt
new file mode 100644
index 00000000..c19a822f
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/JsonFilter.kt
@@ -0,0 +1,66 @@
+package com.vitorpamplona.amethyst.service.relays
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.greenart7c3.nostrsigner.service.relays.EOSETime
+
+class JsonFilter(
+ val ids: List? = null,
+ val authors: List? = null,
+ val kinds: List? = null,
+ val tags: Map>? = null,
+ val since: Map? = null,
+ val until: Long? = null,
+ val limit: Int? = null,
+ val search: String? = null
+) {
+ fun toJson(forRelay: String? = null): String {
+ val jsonObject = JsonObject()
+ ids?.run {
+ jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
+ }
+ authors?.run {
+ jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
+ }
+ kinds?.run {
+ jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
+ }
+ tags?.run {
+ entries.forEach { kv ->
+ jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
+ }
+ }
+ since?.run {
+ if (!isEmpty()) {
+ if (forRelay != null) {
+ val relaySince = get(forRelay)
+ if (relaySince != null) {
+ jsonObject.addProperty("since", relaySince.time)
+ }
+ } else {
+ val jsonObjectSince = JsonObject()
+ entries.forEach { sincePairs ->
+ jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
+ }
+ jsonObject.add("since", jsonObjectSince)
+ }
+ }
+ }
+ until?.run {
+ jsonObject.addProperty("until", until)
+ }
+ limit?.run {
+ jsonObject.addProperty("limit", limit)
+ }
+ search?.run {
+ jsonObject.addProperty("search", search)
+ }
+ return gson.toJson(jsonObject)
+ }
+
+ companion object {
+ val gson: Gson = GsonBuilder().create()
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Relay.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Relay.kt
new file mode 100644
index 00000000..7b2e0c05
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Relay.kt
@@ -0,0 +1,283 @@
+package com.greenart7c3.nostrsigner.service.relays
+
+import android.util.Log
+import com.google.gson.JsonElement
+import com.greenart7c3.nostrsigner.BuildConfig
+import com.greenart7c3.nostrsigner.models.TimeUtils
+import com.greenart7c3.nostrsigner.service.model.Event
+import com.greenart7c3.nostrsigner.service.model.EventInterface
+import com.greenart7c3.nostrsigner.service.model.RelayAuthEvent
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import java.util.concurrent.atomic.AtomicBoolean
+
+enum class FeedType {
+ FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
+}
+
+val COMMON_FEED_TYPES = setOf(
+ FeedType.FOLLOWS,
+ FeedType.PUBLIC_CHATS,
+ FeedType.PRIVATE_DMS,
+ FeedType.GLOBAL
+)
+
+class Relay(
+ var url: String,
+ var read: Boolean = true,
+ var write: Boolean = true,
+ var activeTypes: Set = FeedType.values().toSet()
+) {
+ companion object {
+ // waits 3 minutes to reconnect once things fail
+ const val RECONNECTING_IN_SECONDS = 60 * 3
+ }
+
+ private val httpClient = OkHttpClient.Builder()
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ private var listeners = setOf()
+ private var socket: WebSocket? = null
+ private var isReady: Boolean = false
+
+ var eventDownloadCounterInBytes = 0
+ var eventUploadCounterInBytes = 0
+
+ var spamCounter = 0
+ var errorCounter = 0
+ var pingInMs: Long? = null
+
+ var closingTimeInSeconds = 0L
+
+ var afterEOSE = false
+
+ fun register(listener: Listener) {
+ listeners = listeners.plus(listener)
+ }
+
+ fun unregister(listener: Listener) {
+ listeners = listeners.minus(listener)
+ }
+
+ fun isConnected(): Boolean {
+ return socket != null
+ }
+
+ private var connectingBlock = AtomicBoolean()
+
+ fun connectAndRun(onConnected: (Relay) -> Unit) {
+ // If there is a connection, don't wait.
+ if (connectingBlock.getAndSet(true)) {
+ return
+ }
+
+ if (socket != null) return
+
+ try {
+ val request = Request.Builder()
+ .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
+ .url(url.trim())
+ .build()
+
+ socket = httpClient.newWebSocket(request, RelayListener(onConnected))
+ } catch (e: Exception) {
+ errorCounter++
+ markConnectionAsClosed()
+ Log.e("Relay", "Relay Invalid $url")
+ e.printStackTrace()
+ } finally {
+ connectingBlock.set(false)
+ }
+ }
+
+ inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ markConnectionAsReady(response.receivedResponseAtMillis - response.sentRequestAtMillis)
+
+ // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
+ onConnected(this@Relay)
+
+ listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
+ }
+
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ eventDownloadCounterInBytes += text.bytesUsedInMemory()
+
+ try {
+ processNewRelayMessage(text)
+ } catch (t: Throwable) {
+ t.printStackTrace()
+ text.chunked(2000) { chunked ->
+ listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
+ }
+ }
+ }
+
+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ listeners.forEach {
+ it.onRelayStateChange(
+ this@Relay,
+ Type.DISCONNECTING,
+ null
+ )
+ }
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ markConnectionAsClosed()
+
+ listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ errorCounter++
+
+ socket?.close(1000, "Normal close")
+ // Failures disconnect the relay.
+ markConnectionAsClosed()
+
+ Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
+ t.printStackTrace()
+ listeners.forEach {
+ it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
+ }
+ }
+ }
+
+ fun markConnectionAsReady(pingInMs: Long) {
+ this.afterEOSE = false
+ this.isReady = true
+ this.pingInMs = pingInMs
+ }
+
+ fun markConnectionAsClosed() {
+ this.socket = null
+ this.isReady = false
+ this.afterEOSE = false
+ this.closingTimeInSeconds = TimeUtils.now()
+ }
+
+ fun processNewRelayMessage(newMessage: String) {
+ val msgArray = Event.gson.fromJson(newMessage, JsonElement::class.java).asJsonArray
+ val type = msgArray[0].asString
+ val channel = msgArray[1].asString
+
+ when (type) {
+ "EVENT" -> {
+ val event = Event.fromJson(msgArray[2], false)
+
+ // Log.w("Relay", "Relay onEVENT $url, $channel")
+ listeners.forEach {
+ it.onEvent(this@Relay, channel, event)
+ if (afterEOSE) {
+ it.onRelayStateChange(this@Relay, Type.EOSE, channel)
+ }
+ }
+ }
+ "EOSE" -> listeners.forEach {
+ afterEOSE = true
+ // Log.w("Relay", "Relay onEOSE $url, $channel")
+ it.onRelayStateChange(this@Relay, Type.EOSE, channel)
+ }
+ "NOTICE" -> listeners.forEach {
+ Log.w("Relay", "Relay onNotice $url, $channel")
+ it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
+ }
+ "OK" -> listeners.forEach {
+ Log.w("Relay", "Relay on OK $url, ${msgArray[1].asString}, ${msgArray[2].asBoolean}, ${msgArray[3].asString}")
+ it.onSendResponse(this@Relay, msgArray[1].asString, msgArray[2].asBoolean, msgArray[3].asString)
+ }
+ "AUTH" -> listeners.forEach {
+ // Log.w("Relay", "Relay$url, ${msg[1].asString}")
+ it.onAuth(this@Relay, msgArray[1].asString)
+ }
+ else -> listeners.forEach {
+ // Log.w("Relay", "Relay something else $url, $channel")
+ it.onError(
+ this@Relay,
+ channel,
+ Error("Unknown type $type on channel $channel. Msg was $newMessage")
+ )
+ }
+ }
+ }
+
+ fun disconnect() {
+ // httpClient.dispatcher.executorService.shutdown()
+ closingTimeInSeconds = TimeUtils.now()
+ socket?.close(1000, "Normal close")
+ socket = null
+ isReady = false
+ afterEOSE = false
+ }
+
+ fun send(signedEvent: EventInterface) {
+ if (signedEvent is RelayAuthEvent) {
+ val event = """["AUTH",${signedEvent.toJson()}]"""
+ socket?.send(event)
+ eventUploadCounterInBytes += event.bytesUsedInMemory()
+ }
+
+ if (write) {
+ if (signedEvent !is RelayAuthEvent) {
+ val event = """["EVENT",${signedEvent.toJson()}]"""
+ socket?.send(event)
+ eventUploadCounterInBytes += event.bytesUsedInMemory()
+ }
+ }
+ }
+
+ fun close(subscriptionId: String) {
+ socket?.send("""["CLOSE","$subscriptionId"]""")
+ }
+
+ fun isSameRelayConfig(other: Relay): Boolean {
+ return url == other.url &&
+ write == other.write &&
+ read == other.read &&
+ activeTypes == other.activeTypes
+ }
+
+ enum class Type {
+ // Websocket connected
+ CONNECT,
+
+ // Websocket disconnecting
+ DISCONNECTING,
+
+ // Websocket disconnected
+ DISCONNECT,
+
+ // End Of Stored Events
+ EOSE
+ }
+
+ interface Listener {
+ /**
+ * A new message was received
+ */
+ fun onEvent(relay: Relay, subscriptionId: String, event: Event)
+
+ fun onError(relay: Relay, subscriptionId: String, error: Error)
+
+ fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
+
+ fun onAuth(relay: Relay, challenge: String)
+
+ /**
+ * Connected to or disconnected from a relay
+ *
+ * @param type is 0 for disconnect and 1 for connect
+ */
+ fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
+ }
+}
+
+fun String.bytesUsedInMemory(): Int {
+ return (8 * ((((this.length) * 2) + 45) / 8))
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Subscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Subscription.kt
new file mode 100644
index 00000000..a590140e
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/Subscription.kt
@@ -0,0 +1,31 @@
+package com.vitorpamplona.amethyst.service.relays
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.greenart7c3.nostrsigner.service.relays.TypedFilter
+import java.util.UUID
+
+data class Subscription(
+ val id: String = UUID.randomUUID().toString().substring(0, 4),
+ val onEOSE: ((Long, String) -> Unit)? = null
+) {
+ var typedFilters: List? = null // Inactive when null
+
+ fun updateEOSE(time: Long, relay: String) {
+ onEOSE?.let { it(time, relay) }
+ }
+
+ fun toJson(): String {
+ return GsonBuilder().create().toJson(toJsonObject())
+ }
+
+ fun toJsonObject(): JsonObject {
+ val jsonObject = JsonObject()
+ jsonObject.addProperty("id", id)
+ typedFilters?.run {
+ jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
+ }
+ return jsonObject
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/TypedFilter.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/TypedFilter.kt
new file mode 100644
index 00000000..b4151115
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/relays/TypedFilter.kt
@@ -0,0 +1,64 @@
+package com.greenart7c3.nostrsigner.service.relays
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.vitorpamplona.amethyst.service.relays.JsonFilter
+
+class TypedFilter(
+ val types: Set,
+ val filter: JsonFilter
+) {
+
+ fun toJson(): String {
+ return GsonBuilder().create().toJson(toJsonObject())
+ }
+
+ fun toJsonObject(): JsonObject {
+ val jsonObject = JsonObject()
+ jsonObject.add("types", typesToJson(types))
+ jsonObject.add("filter", filterToJson(filter))
+ return jsonObject
+ }
+
+ fun typesToJson(types: Set): JsonArray {
+ return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
+ }
+
+ fun filterToJson(filter: JsonFilter): JsonObject {
+ val jsonObject = JsonObject()
+ filter.ids?.run {
+ jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
+ }
+ filter.authors?.run {
+ jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
+ }
+ filter.kinds?.run {
+ jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
+ }
+ filter.tags?.run {
+ entries.forEach { kv ->
+ jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
+ }
+ }
+ /*
+ Does not include since in the json comparison
+ filter.since?.run {
+ val jsonObjectSince = JsonObject()
+ entries.forEach { sincePairs ->
+ jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
+ }
+ jsonObject.add("since", jsonObjectSince)
+ }*/
+ filter.until?.run {
+ jsonObject.addProperty("until", filter.until)
+ }
+ filter.limit?.run {
+ jsonObject.addProperty("limit", filter.limit)
+ }
+ filter.search?.run {
+ jsonObject.addProperty("search", filter.search)
+ }
+ return jsonObject
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
new file mode 100644
index 00000000..2e759b3e
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountScreen.kt
@@ -0,0 +1,164 @@
+package com.greenart7c3.nostrsigner.ui
+
+import android.annotation.SuppressLint
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.Absolute.Center
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Button
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.greenart7c3.nostrsigner.MainActivity
+import com.greenart7c3.nostrsigner.models.Account
+import com.greenart7c3.nostrsigner.models.IntentData
+import com.greenart7c3.nostrsigner.service.model.Event
+import com.greenart7c3.nostrsigner.service.relays.Relay
+import com.greenart7c3.nostrsigner.ui.actions.ButtonBorder
+import com.greenart7c3.nostrsigner.ui.actions.RelaySelectionDialog
+import org.json.JSONObject
+
+@Composable
+fun AccountScreen(
+ accountStateViewModel: AccountStateViewModel,
+ event: IntentData?,
+ mainActivity: MainActivity
+) {
+ val accountState by accountStateViewModel.accountContent.collectAsState()
+
+ Column {
+ Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
+ when (state) {
+ is AccountState.LoggedOff -> {
+ LoginPage(accountStateViewModel)
+ }
+ is AccountState.LoggedIn -> {
+ MainScreen(state.account, event, mainActivity)
+ }
+ }
+ }
+ }
+}
+
+@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
+@Composable
+fun MainScreen(account: Account, json: IntentData?, mainActivity: MainActivity) {
+ val event = remember {
+ mutableStateOf(null)
+ }
+
+ val relays = listOf().toMutableList()
+ var showRelaysDialog by remember {
+ mutableStateOf(false)
+ }
+ val relaysToPost = remember { mutableListOf() }
+
+ Scaffold(
+ bottomBar = {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(10.dp),
+ Arrangement.SpaceBetween
+ ) {
+ Button(
+ shape = ButtonBorder,
+ onClick = {
+ showRelaysDialog = true
+ }
+ ) {
+ Text("Relays")
+ }
+
+ Button(
+ shape = ButtonBorder,
+ onClick = {
+ if (relaysToPost.isEmpty()) {
+ relaysToPost.addAll(relays)
+ }
+
+ if (event.value == null) {
+ return@Button
+ }
+ val signedEvent = Event.create(
+ account.keyPair.privKey!!,
+ event.value!!.kind,
+ event.value!!.tags,
+ event.value!!.content,
+ event.value!!.createdAt
+ )
+// val rawJson = signedEvent.toJson()
+// val resultIntent = Intent()
+
+ relaysToPost.forEach {
+ it.connectAndRun { relay ->
+ relay.send(signedEvent)
+ }
+ }
+
+// resultIntent.putExtra("signed_event", rawJson)
+// mainActivity.setResult(Activity.RESULT_OK, resultIntent)
+ mainActivity.finish()
+ }
+ ) {
+ Text("Post")
+ }
+ }
+ }
+ ) {
+ if (json == null) {
+ Column(
+ Modifier.fillMaxSize(),
+ Arrangement.Center,
+ Alignment.CenterHorizontally
+ ) {
+ Text("No event to sign")
+ }
+ } else {
+ json.let {
+ val data = it.data.replace("nostrsigner:", "")
+ event.value = Event.fromJson(data)
+ val tempRelays = it.relays
+ relays.clear()
+ tempRelays.forEach { url ->
+ relays.add(Relay(url))
+ }
+ Column(
+ Modifier.fillMaxSize()
+ ) {
+ Text(
+ modifier = Modifier.padding(5.dp),
+ text = JSONObject(data).toString(2)
+ )
+ }
+ }
+
+ if (showRelaysDialog) {
+ RelaySelectionDialog(
+ list = relays,
+ selectRelays = relaysToPost,
+ onClose = {
+ showRelaysDialog = false
+ },
+ onPost = {
+ relaysToPost.clear()
+ relaysToPost.addAll(it)
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountState.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountState.kt
new file mode 100644
index 00000000..94863cd6
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountState.kt
@@ -0,0 +1,8 @@
+package com.greenart7c3.nostrsigner.ui
+
+import com.greenart7c3.nostrsigner.models.Account
+
+sealed class AccountState {
+ object LoggedOff : AccountState()
+ class LoggedIn(val account: Account) : AccountState()
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt
new file mode 100644
index 00000000..874cbdf0
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt
@@ -0,0 +1,65 @@
+package com.greenart7c3.nostrsigner.ui
+
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import com.greenart7c3.nostrsigner.LocalPreferences
+import com.greenart7c3.nostrsigner.models.Account
+import com.greenart7c3.nostrsigner.service.KeyPair
+import com.greenart7c3.nostrsigner.service.bechToBytes
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+@Stable
+class AccountStateViewModel : ViewModel() {
+ private val _accountContent = MutableStateFlow(AccountState.LoggedOff)
+ val accountContent = _accountContent.asStateFlow()
+
+ init {
+ tryLoginExistingAccount()
+ }
+
+ private fun tryLoginExistingAccount() {
+ LocalPreferences.loadFromEncryptedStorage()?.let {
+ startUI(it)
+ }
+ }
+
+ fun startUI(key: String) {
+ if (!key.startsWith("nsec")) {
+ throw Exception("Not nsec key")
+ }
+
+ val account = Account(KeyPair(privKey = key.bechToBytes()))
+ LocalPreferences.updatePrefsForLogin(account)
+ startUI(account)
+ }
+
+ fun newKey() {
+ val account = Account(KeyPair())
+ LocalPreferences.updatePrefsForLogin(account)
+ startUI(account)
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ fun startUI(account: Account) {
+ if (account.keyPair.privKey != null) {
+ _accountContent.update { AccountState.LoggedIn(account) }
+ }
+
+ GlobalScope.launch(Dispatchers.Main) {
+ account.saveable.observeForever(saveListener)
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private val saveListener: (com.greenart7c3.nostrsigner.models.AccountState) -> Unit = {
+ GlobalScope.launch(Dispatchers.IO) {
+ LocalPreferences.saveToEncryptedStorage(it.account)
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt
new file mode 100644
index 00000000..ac272be4
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt
@@ -0,0 +1,229 @@
+package com.greenart7c3.nostrsigner.ui
+
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Visibility
+import androidx.compose.material.icons.outlined.VisibilityOff
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofill
+import androidx.compose.ui.platform.LocalAutofillTree
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import com.greenart7c3.nostrsigner.R
+import com.greenart7c3.nostrsigner.ui.theme.Font14SP
+import com.greenart7c3.nostrsigner.ui.theme.Size35dp
+import com.greenart7c3.nostrsigner.ui.theme.placeholderText
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun LoginPage(
+ accountViewModel: AccountStateViewModel
+) {
+ val key = remember { mutableStateOf(TextFieldValue("")) }
+ var errorMessage by remember { mutableStateOf("") }
+ var dialogOpen by remember {
+ mutableStateOf(false)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ // The first child is glued to the top.
+ // Hence we have nothing at the top, an empty box is used.
+ Box(modifier = Modifier.height(0.dp))
+
+ // The second child, this column, is centered vertically.
+ Column(
+ modifier = Modifier
+ .padding(20.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(40.dp))
+
+ var showPassword by remember {
+ mutableStateOf(false)
+ }
+
+ val autofillNode = AutofillNode(
+ autofillTypes = listOf(AutofillType.Password),
+ onFill = { key.value = TextFieldValue(it) }
+ )
+ val autofill = LocalAutofill.current
+ LocalAutofillTree.current += autofillNode
+
+ OutlinedTextField(
+ modifier = Modifier
+ .onGloballyPositioned { coordinates ->
+ autofillNode.boundingBox = coordinates.boundsInWindow()
+ }
+ .onFocusChanged { focusState ->
+ autofill?.run {
+ if (focusState.isFocused) {
+ requestAutofillForNode(autofillNode)
+ } else {
+ cancelAutofillForNode(autofillNode)
+ }
+ }
+ },
+ value = key.value,
+ onValueChange = { key.value = it },
+ keyboardOptions = KeyboardOptions(
+ autoCorrect = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Go
+ ),
+ placeholder = {
+ Text(
+ text = "nsec..",
+ color = MaterialTheme.colors.placeholderText
+ )
+ },
+ trailingIcon = {
+ Row {
+ IconButton(onClick = { showPassword = !showPassword }) {
+ Icon(
+ imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
+ contentDescription = if (showPassword) {
+ "Show password"
+ } else {
+ "Hide password"
+ }
+ )
+ }
+ }
+ },
+ leadingIcon = {
+ if (dialogOpen) {
+ SimpleQrCodeScanner {
+ dialogOpen = false
+ if (!it.isNullOrEmpty()) {
+ key.value = TextFieldValue(it)
+ }
+ }
+ }
+ IconButton(onClick = { dialogOpen = true }) {
+ Icon(
+ painter = painterResource(R.drawable.ic_qrcode),
+ null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colors.primary
+ )
+ }
+ },
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ try {
+ accountViewModel.startUI(key.value.text)
+ } catch (e: Exception) {
+ errorMessage = "Invalid key"
+ }
+ }
+ )
+ )
+ if (errorMessage.isNotBlank()) {
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colors.error,
+ style = MaterialTheme.typography.caption
+ )
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
+ Button(
+ onClick = {
+ if (key.value.text.isBlank()) {
+ errorMessage = "Key is required"
+ }
+
+ if (key.value.text.isNotBlank()) {
+ try {
+ accountViewModel.startUI(key.value.text)
+ } catch (e: Exception) {
+ Log.e("Login", "Could not sign in", e)
+ errorMessage = "Invalid key"
+ }
+ }
+ },
+ shape = RoundedCornerShape(Size35dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ )
+ ) {
+ Text(text = "Login")
+ }
+ }
+ }
+
+ // The last child is glued to the bottom.
+ ClickableText(
+ text = AnnotatedString("Generate a new key"),
+ modifier = Modifier
+ .padding(20.dp)
+ .fillMaxWidth(),
+ onClick = {
+ accountViewModel.newKey()
+ },
+ style = TextStyle(
+ fontSize = Font14SP,
+ textDecoration = TextDecoration.Underline,
+ color = MaterialTheme.colors.primary,
+ textAlign = TextAlign.Center
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/QrCodeScanner.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/QrCodeScanner.kt
new file mode 100644
index 00000000..561deef0
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/QrCodeScanner.kt
@@ -0,0 +1,36 @@
+package com.greenart7c3.nostrsigner.ui
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import com.google.zxing.client.android.Intents
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
+
+@Composable
+fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ val qrLauncher =
+ rememberLauncherForActivityResult(ScanContract()) {
+ if (it.contents != null) {
+ onScan(it.contents)
+ } else {
+ onScan(null)
+ }
+ }
+
+ val scanOptions = ScanOptions().apply {
+ setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ setPrompt("Point to the QR Code")
+ setBeepEnabled(false)
+ setOrientationLocked(false)
+ addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
+ }
+
+ DisposableEffect(lifecycleOwner) {
+ qrLauncher.launch(scanOptions)
+ onDispose { }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/RelaySelectionDialog.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/RelaySelectionDialog.kt
new file mode 100644
index 00000000..b1063884
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/RelaySelectionDialog.kt
@@ -0,0 +1,234 @@
+package com.greenart7c3.nostrsigner.ui.actions
+
+import android.widget.Toast
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Switch
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.greenart7c3.nostrsigner.service.relays.Relay
+import kotlinx.coroutines.launch
+
+data class RelayList(
+ val relay: Relay,
+ val isSelected: Boolean
+)
+
+val ButtonBorder = RoundedCornerShape(20.dp)
+val Size20Modifier = Modifier.size(20.dp)
+
+@Composable
+fun CloseButton(onCancel: () -> Unit) {
+ Button(
+ onClick = {
+ onCancel()
+ },
+ shape = ButtonBorder,
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = Color.Gray
+ )
+ ) {
+ CloseIcon()
+ }
+}
+
+@Composable
+fun PostButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
+ Button(
+ modifier = modifier,
+ onClick = {
+ if (isActive) {
+ onPost()
+ }
+ },
+ shape = ButtonBorder,
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
+ ),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(text = "Post", color = Color.White)
+ }
+}
+
+@Composable
+fun CloseIcon() {
+ Icon(
+ Icons.Default.Cancel,
+ contentDescription = "Cancel",
+ modifier = Size20Modifier,
+ tint = Color.White
+ )
+}
+
+@Composable
+fun RelaySelectionDialog(
+ list: List,
+ selectRelays: List,
+ onClose: () -> Unit,
+ onPost: (list: List) -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var relays by remember {
+ mutableStateOf(
+ list.map {
+ RelayList(
+ it,
+ if (selectRelays.isNotEmpty()) selectRelays.any { relay -> it.url == relay.url } else true
+ )
+ }
+ )
+ }
+
+ var selected by remember {
+ mutableStateOf(true)
+ }
+
+ Dialog(
+ onDismissRequest = { onClose() },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnClickOutside = false,
+ decorFitsSystemWindows = false
+ )
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(start = 10.dp, end = 10.dp, top = 10.dp)
+
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(
+ onCancel = {
+ onClose()
+ }
+ )
+
+ PostButton(
+ onPost = {
+ val selectedRelays = relays.filter { it.isSelected }
+ if (selectedRelays.isEmpty()) {
+ scope.launch {
+ Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show()
+ }
+ return@PostButton
+ }
+ onPost(selectedRelays.map { it.relay })
+ onClose()
+ },
+ isActive = true
+ )
+ }
+
+ RelaySwitch(
+ text = "Select/Deselect all",
+ checked = selected,
+ onClick = {
+ selected = !selected
+ relays = relays.mapIndexed { _, item ->
+ item.copy(isSelected = selected)
+ }
+ }
+ )
+
+ LazyColumn(
+ contentPadding = PaddingValues(
+ top = 10.dp,
+ bottom = 10.dp
+ )
+ ) {
+ itemsIndexed(
+ relays,
+ key = { _, item -> item.relay.url }
+ ) { index, item ->
+ RelaySwitch(
+ text = item.relay.url
+ .removePrefix("ws://")
+ .removePrefix("wss://")
+ .removeSuffix("/"),
+ checked = item.isSelected,
+ onClick = {
+ relays = relays.mapIndexed { j, item ->
+ if (index == j) {
+ item.copy(isSelected = !item.isSelected)
+ } else {
+ item
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun RelaySwitch(text: String, checked: Boolean, onClick: () -> Unit, onLongPress: () -> Unit = { }) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongPress
+ )
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = text
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = {
+ onClick()
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BundledUpdate.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BundledUpdate.kt
new file mode 100644
index 00000000..0b258828
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BundledUpdate.kt
@@ -0,0 +1,49 @@
+package com.greenart7c3.nostrsigner.ui.components
+
+import androidx.compose.runtime.Stable
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * This class is designed to have a waiting time between two calls of invalidate
+ */
+@Stable
+class BundledUpdate(
+ val delay: Long,
+ val dispatcher: CoroutineDispatcher = Dispatchers.Default
+) {
+ private var onlyOneInBlock = AtomicBoolean()
+ private var invalidatesAgain = false
+
+ fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) {
+ if (onlyOneInBlock.getAndSet(true)) {
+ if (!ignoreIfDoing) {
+ invalidatesAgain = true
+ }
+ return
+ }
+
+ val scope = CoroutineScope(Job() + dispatcher)
+ scope.launch {
+ try {
+ onUpdate()
+ delay(delay)
+ if (invalidatesAgain) {
+ onUpdate()
+ }
+ } finally {
+ withContext(NonCancellable) {
+ invalidatesAgain = false
+ onlyOneInBlock.set(false)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Theme.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Theme.kt
new file mode 100644
index 00000000..ecb26de3
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Theme.kt
@@ -0,0 +1,79 @@
+package com.greenart7c3.nostrsigner.ui.theme
+
+import android.app.Activity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Colors
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Shapes
+import androidx.compose.material.darkColors
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(4.dp),
+ large = RoundedCornerShape(0.dp)
+)
+
+val Font14SP = 14.sp
+
+val Size35dp = 35.dp
+
+val Purple200 = Color(0xFFBB86FC)
+val Purple500 = Color(0xFF6200EE)
+val Purple700 = Color(0xFF3700B3)
+val Teal200 = Color(0xFF03DAC5)
+
+private val DarkColorPalette = darkColors(
+ primary = Purple200,
+ primaryVariant = Purple700,
+ secondary = Teal200,
+ secondaryVariant = Purple200
+)
+
+private val LightColorPalette = lightColors(
+ primary = Purple500,
+ primaryVariant = Purple700,
+ secondary = Teal200,
+ secondaryVariant = Purple500
+)
+
+private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.32f)
+private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32f)
+
+val Colors.placeholderText: Color
+ get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
+
+@Composable
+fun NostrSignerTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colors = if (darkTheme) DarkColorPalette else LightColorPalette
+
+ MaterialTheme(
+ colors = colors,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ if (darkTheme) {
+ window.statusBarColor = colors.background.toArgb()
+ } else {
+ window.statusBarColor = colors.primary.toArgb()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Type.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Type.kt
new file mode 100644
index 00000000..116d4aab
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/theme/Type.kt
@@ -0,0 +1,32 @@
+package com.greenart7c3.nostrsigner.ui.theme
+
+import androidx.compose.material.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ body1 = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_qrcode.png b/app/src/main/res/drawable/ic_qrcode.png
new file mode 100644
index 00000000..31b0439c
Binary files /dev/null and b/app/src/main/res/drawable/ic_qrcode.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..9b32f242
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..44899b3f
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Nostr Signer
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..bc05ca1e
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/ExampleUnitTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/ExampleUnitTest.kt
new file mode 100644
index 00000000..a90e9de3
--- /dev/null
+++ b/app/src/test/java/com/greenart7c3/nostrsigner/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.greenart7c3.nostrsigner
+
+import junit.framework.TestCase.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 00000000..439c37c4
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,50 @@
+buildscript {
+ ext {
+ fragment_version = "1.6.0"
+ lifecycle_version = '2.6.1'
+ compose_ui_version = '1.4.3'
+ nav_version = "2.6.0"
+ room_version = "2.4.3"
+ accompanist_version = '0.30.0'
+ coil_version = '2.4.0'
+ vico_version = '1.7.1'
+ exoplayer_version = '1.1.0'
+ }
+}
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.0.2' apply false
+ id 'com.android.library' version '8.0.2' apply false
+ id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
+}
+
+task installGitHook(type: Copy) {
+ from new File(rootProject.rootDir, 'git-hooks/pre-commit')
+ from new File(rootProject.rootDir, 'git-hooks/pre-push')
+ into { new File(rootProject.rootDir, '.git/hooks') }
+ fileMode 0777
+}
+
+tasks.getByPath(':app:preBuild').dependsOn installGitHook
+
+subprojects {
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ if (project.findProperty("composeCompilerReports") == "true") {
+ freeCompilerArgs += [
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
+ project.buildDir.absolutePath + "/compose_compiler"
+ ]
+ }
+ if (project.findProperty("composeCompilerMetrics") == "true") {
+ freeCompilerArgs += [
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
+ project.buildDir.absolutePath + "/compose_compiler"
+ ]
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit
new file mode 100644
index 00000000..c913761b
--- /dev/null
+++ b/git-hooks/pre-commit
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+GREEN='\033[0;32m'
+NO_COLOR='\033[0m'
+
+echo "*********************************************************"
+echo "Running git pre-commit hook. Running Static analysis... "
+echo "*********************************************************"
+
+./gradlew ktlintCheck
+
+status=$?
+
+if [ "$status" = 0 ] ; then
+ echo "Static analysis found no problems."
+ exit 0
+else
+ echo "*********************************************************"
+ echo 1>&2 "Static analysis found violations it could not fix."
+ printf "Run ${GREEN}./gradlew ktlintFormat${NO_COLOR} to fix formatting related issues...\n"
+ echo "*********************************************************"
+ exit 1
+fi
diff --git a/git-hooks/pre-push b/git-hooks/pre-push
new file mode 100644
index 00000000..19c523c3
--- /dev/null
+++ b/git-hooks/pre-push
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+GREEN='\033[0;32m'
+NO_COLOR='\033[0m'
+
+echo "*********************************************************"
+echo "Running git pre-push hook. Running test... "
+echo "*********************************************************"
+
+./gradlew test
+
+status=$?
+
+if [ "$status" = 0 ] ; then
+ echo "All test passed."
+ exit 0
+else
+ echo "*********************************************************"
+ echo 1>&2 "Failing test"
+ printf "Run ${GREEN}./gradlew test${NO_COLOR} to make sure you have all tests green before pushing...\n"
+ echo "*********************************************************"
+ exit 1
+fi
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..a9bb28f5
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,27 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.enableR8.fullMode=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
+org.gradle.unsafe.configuration-cache=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..971908c9
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jul 14 10:23:07 BRT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 00000000..4f906e0c
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 00000000..6ecf5781
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url "https://jitpack.io" }
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url "https://jitpack.io" }
+ }
+}
+rootProject.name = "Nostr Signer"
+include ':app'