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'