Add secure key storage
File uploads
This commit is contained in:
parent
c4ec164047
commit
e0884c17f1
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetDropDown">
|
|
||||||
<targetSelectedWithDropDown>
|
|
||||||
<Target>
|
|
||||||
<type value="QUICK_BOOT_TARGET" />
|
|
||||||
<deviceKey>
|
|
||||||
<Key>
|
|
||||||
<type value="VIRTUAL_DEVICE_PATH" />
|
|
||||||
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_34_extension_level_7_arm64-v8a.avd" />
|
|
||||||
</Key>
|
|
||||||
</deviceKey>
|
|
||||||
</Target>
|
|
||||||
</targetSelectedWithDropDown>
|
|
||||||
<timeTargetWasSelectedWithDropDown value="2023-07-17T10:10:40.050367Z" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.7.20" />
|
<option name="version" value="1.9.10" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -9,10 +10,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "social.snort.app"
|
applicationId "social.snort.app"
|
||||||
minSdk 24
|
minSdk 26
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
versionCode 1
|
versionCode 3
|
||||||
versionName "1.0.11"
|
versionName "1.0.12"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -50,6 +51,12 @@ dependencies {
|
|||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
|
||||||
implementation 'androidx.activity:activity-compose:1.5.1'
|
implementation 'androidx.activity:activity-compose:1.5.1'
|
||||||
implementation 'androidx.webkit:webkit:1.4.0'
|
implementation 'androidx.webkit:webkit:1.4.0'
|
||||||
|
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1'
|
||||||
|
|
||||||
|
// Bitcoin secp256k1 bindings to Android
|
||||||
|
api 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
|
@ -3,7 +3,8 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:name=".Snort"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@ -11,6 +12,8 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@ -24,4 +27,7 @@
|
|||||||
</application>
|
</application>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CLIPBOARD" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CLIPBOARD" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
</manifest>
|
</manifest>
|
29
app/src/main/java/social/snort/app/EncryptedStorage.kt
Normal file
29
app/src/main/java/social/snort/app/EncryptedStorage.kt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package social.snort.app
|
||||||
|
|
||||||
|
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 = Snort.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
|
||||||
|
}
|
||||||
|
}
|
19
app/src/main/java/social/snort/app/Event.kt
Normal file
19
app/src/main/java/social/snort/app/Event.kt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package social.snort.app
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Event(
|
||||||
|
val id: HexKey,
|
||||||
|
@SerialName("pubkey")
|
||||||
|
val pubKey: HexKey,
|
||||||
|
@SerialName("created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
val kind: Int,
|
||||||
|
val tags: List<List<String>>,
|
||||||
|
val content: String,
|
||||||
|
|
||||||
|
val sig: HexKey? = null
|
||||||
|
)
|
70
app/src/main/java/social/snort/app/HexUtils.kt
Normal file
70
app/src/main/java/social/snort/app/HexUtils.kt
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package social.snort.app
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
object HexValidator {
|
||||||
|
private fun isHex2(c: Char): Boolean {
|
||||||
|
return when (c) {
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isHex(hex: String?): Boolean {
|
||||||
|
if (hex == null) return false
|
||||||
|
var isHex = true
|
||||||
|
for (c in hex.toCharArray()) {
|
||||||
|
if (!isHex2(c)) {
|
||||||
|
isHex = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isHex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Hex {
|
||||||
|
private val hexCode = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
|
||||||
|
|
||||||
|
// Faster if no calculations are needed.
|
||||||
|
private fun hexToBin(ch: Char): Int = when (ch) {
|
||||||
|
in '0'..'9' -> ch - '0'
|
||||||
|
in 'a'..'f' -> ch - 'a' + 10
|
||||||
|
in 'A'..'F' -> ch - 'A' + 10
|
||||||
|
else -> throw IllegalArgumentException("illegal hex character: $ch")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun decode(hex: String): ByteArray {
|
||||||
|
// faster version of hex decoder
|
||||||
|
require(hex.length % 2 == 0)
|
||||||
|
val outSize = hex.length / 2
|
||||||
|
val out = ByteArray(outSize)
|
||||||
|
|
||||||
|
for (i in 0 until outSize) {
|
||||||
|
out[i] = (hexToBin(hex[2 * i]) * 16 + hexToBin(hex[2 * i + 1])).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun encode(input: ByteArray): String {
|
||||||
|
val len = input.size
|
||||||
|
val out = CharArray(len * 2)
|
||||||
|
for (i in 0 until len) {
|
||||||
|
out[i*2] = hexCode[(input[i].toInt() shr 4) and 0xF]
|
||||||
|
out[i*2+1] = hexCode[input[i].toInt() and 0xF]
|
||||||
|
}
|
||||||
|
return String(out)
|
||||||
|
}
|
||||||
|
}
|
@ -3,16 +3,22 @@ package social.snort.app
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.webkit.PermissionRequest
|
||||||
|
import android.webkit.ValueCallback
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.webkit.WebViewAssetLoader
|
import androidx.webkit.WebViewAssetLoader
|
||||||
import androidx.webkit.WebViewClientCompat
|
import androidx.webkit.WebViewClientCompat
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var getContentCallback: ValueCallback<Array<Uri>>? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@ -22,11 +28,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
webView.settings.domStorageEnabled = true
|
webView.settings.domStorageEnabled = true
|
||||||
webView.settings.javaScriptEnabled = true
|
webView.settings.javaScriptEnabled = true
|
||||||
webView.settings.databaseEnabled = true;
|
webView.settings.databaseEnabled = true;
|
||||||
|
val getContent =
|
||||||
|
registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
getContentCallback!!.onReceiveValue(arrayOf(uri))
|
||||||
|
} else {
|
||||||
|
getContentCallback!!.onReceiveValue(emptyArray())
|
||||||
|
}
|
||||||
|
getContentCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
val assetLoader = WebViewAssetLoader.Builder()
|
val assetLoader = WebViewAssetLoader.Builder()
|
||||||
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this))
|
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(this)).build()
|
||||||
.build()
|
|
||||||
webView.webViewClient = LocalContentWebViewClient(assetLoader)
|
webView.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||||
|
webView.addJavascriptInterface(Nip7Extension(), "nostr_os");
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
request.grant(request.resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowFileChooser(
|
||||||
|
vw: WebView,
|
||||||
|
filePathCallback: ValueCallback<Array<Uri>>,
|
||||||
|
fileChooserParams: FileChooserParams
|
||||||
|
): Boolean {
|
||||||
|
getContentCallback = filePathCallback
|
||||||
|
getContent.launch("*/*")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
webView.loadUrl("https://appassets.androidplatform.net/")
|
webView.loadUrl("https://appassets.androidplatform.net/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -35,8 +65,7 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa
|
|||||||
WebViewClientCompat() {
|
WebViewClientCompat() {
|
||||||
@RequiresApi(21)
|
@RequiresApi(21)
|
||||||
override fun shouldInterceptRequest(
|
override fun shouldInterceptRequest(
|
||||||
view: WebView,
|
view: WebView, request: WebResourceRequest
|
||||||
request: WebResourceRequest
|
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
// rewrite root url to index.html
|
// rewrite root url to index.html
|
||||||
if (request.url.path.equals("/") && request.url.host.equals("appassets.androidplatform.net")) {
|
if (request.url.path.equals("/") && request.url.host.equals("appassets.androidplatform.net")) {
|
||||||
@ -49,8 +78,12 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa
|
|||||||
if (request.url.host.equals("appassets.androidplatform.net")) {
|
if (request.url.host.equals("appassets.androidplatform.net")) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val intent = Intent(Intent.ACTION_VIEW, request.url)
|
try {
|
||||||
view.context.startActivity(intent)
|
val intent = Intent(Intent.ACTION_VIEW, request.url)
|
||||||
|
view.context.startActivity(intent)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
117
app/src/main/java/social/snort/app/Nip7Extension.kt
Normal file
117
app/src/main/java/social/snort/app/Nip7Extension.kt
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package social.snort.app
|
||||||
|
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
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
|
||||||
|
|
||||||
|
class Nip7Extension {
|
||||||
|
private val secp256k1 = Secp256k1.get()
|
||||||
|
private val h02 = ByteArray(1) { 0x02 }
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
object PrefKeys {
|
||||||
|
const val PrivateKey = "private_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun getPublicKey(): String {
|
||||||
|
val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null)
|
||||||
|
if (key != null) {
|
||||||
|
return Hex.encode(
|
||||||
|
secp256k1.pubKeyCompress(secp256k1.pubkeyCreate(Hex.decode(key))).copyOfRange(1, 33)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error("Missing private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun signEvent(ev: String): String {
|
||||||
|
val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null)
|
||||||
|
?: throw error("Missing private key")
|
||||||
|
|
||||||
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
|
println(ev)
|
||||||
|
val evParsed = json.decodeFromString<Event>(ev)
|
||||||
|
|
||||||
|
val idArray = buildJsonArray {
|
||||||
|
add(0)
|
||||||
|
add(evParsed.pubKey)
|
||||||
|
add(evParsed.createdAt)
|
||||||
|
add(evParsed.kind)
|
||||||
|
add(buildJsonArray {
|
||||||
|
evParsed.tags.forEach { x -> add(buildJsonArray { x.forEach { y -> add(y) } }) }
|
||||||
|
})
|
||||||
|
add(evParsed.content)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
println(idArray)
|
||||||
|
val evId = MessageDigest.getInstance("SHA-256").digest(idArray.encodeToByteArray())
|
||||||
|
var sig = Hex.encode(secp256k1.signSchnorr(evId, Hex.decode(key), null))
|
||||||
|
|
||||||
|
return json.encodeToString(
|
||||||
|
Event(
|
||||||
|
Hex.encode(evId),
|
||||||
|
evParsed.pubKey,
|
||||||
|
evParsed.createdAt,
|
||||||
|
evParsed.kind,
|
||||||
|
evParsed.tags,
|
||||||
|
evParsed.content,
|
||||||
|
sig
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun nip04_encrypt(msg: String, toKey: String): String {
|
||||||
|
val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null)
|
||||||
|
?: throw error("Missing private key")
|
||||||
|
val sharedSecret = getSharedSecretNIP04(Hex.decode(key), Hex.decode(toKey))
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun nip04_decrypt(msg: String, fromKey: String): String {
|
||||||
|
val key = EncryptedStorage.preferences().getString(PrefKeys.PrivateKey, null)
|
||||||
|
?: throw error("Missing private key")
|
||||||
|
val sharedSecret = getSharedSecretNIP04(Hex.decode(key), Hex.decode(fromKey))
|
||||||
|
|
||||||
|
val msgSplit = msg.split("?iv=")
|
||||||
|
val cipherText = Base64.getDecoder().decode(msgSplit[0])
|
||||||
|
val iv = Base64.getDecoder().decode(msgSplit[1])
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
|
||||||
|
return String(cipher.doFinal(cipherText))
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun saveKey(key: String) {
|
||||||
|
EncryptedStorage.preferences().edit().apply {
|
||||||
|
putString(PrefKeys.PrivateKey, key)
|
||||||
|
}.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return 32B shared secret
|
||||||
|
*/
|
||||||
|
private fun getSharedSecretNIP04(privateKey: ByteArray, pubKey: ByteArray): ByteArray {
|
||||||
|
return secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey)
|
||||||
|
.copyOfRange(1, 33)
|
||||||
|
}
|
||||||
|
}
|
15
app/src/main/java/social/snort/app/Snort.kt
Normal file
15
app/src/main/java/social/snort/app/Snort.kt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package social.snort.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class Snort : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
lateinit var instance: Snort
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
@ -3,4 +3,5 @@ plugins {
|
|||||||
id 'com.android.application' version '8.0.2' apply false
|
id 'com.android.application' version '8.0.2' apply false
|
||||||
id 'com.android.library' 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
|
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10'
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user