mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
519 lines
20 KiB
Kotlin
519 lines
20 KiB
Kotlin
package com.greenart7c3.nostrsigner
|
|
|
|
import android.app.Application
|
|
import android.content.Intent
|
|
import android.net.NetworkCapabilities
|
|
import android.util.Log
|
|
import androidx.appcompat.app.AppCompatDelegate
|
|
import androidx.collection.LruCache
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.core.os.LocaleListCompat
|
|
import androidx.lifecycle.DefaultLifecycleObserver
|
|
import androidx.lifecycle.LifecycleObserver
|
|
import androidx.lifecycle.LifecycleOwner
|
|
import androidx.lifecycle.ProcessLifecycleOwner
|
|
import androidx.work.ExistingPeriodicWorkPolicy
|
|
import androidx.work.PeriodicWorkRequestBuilder
|
|
import androidx.work.WorkManager
|
|
import com.greenart7c3.nostrsigner.database.AppDatabase
|
|
import com.greenart7c3.nostrsigner.database.HistoryDatabase
|
|
import com.greenart7c3.nostrsigner.database.LogDatabase
|
|
import com.greenart7c3.nostrsigner.models.Account
|
|
import com.greenart7c3.nostrsigner.models.AmberSettings
|
|
import com.greenart7c3.nostrsigner.models.FeedbackType
|
|
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
|
|
import com.greenart7c3.nostrsigner.okhttp.OkHttpWebSocket
|
|
import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton
|
|
import com.greenart7c3.nostrsigner.relays.AmberRelayStats
|
|
import com.greenart7c3.nostrsigner.relays.NostrClientLoggerListener
|
|
import com.greenart7c3.nostrsigner.service.ClearLogsWorker
|
|
import com.greenart7c3.nostrsigner.service.ConnectivityService
|
|
import com.greenart7c3.nostrsigner.service.NotificationSubscription
|
|
import com.greenart7c3.nostrsigner.service.ProfileSubscription
|
|
import com.greenart7c3.nostrsigner.service.crashreports.CrashReportCache
|
|
import com.greenart7c3.nostrsigner.service.crashreports.UnexpectedCrashSaver
|
|
import com.greenart7c3.nostrsigner.ui.RememberType
|
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
|
|
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
|
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
|
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
|
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
|
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
|
import java.lang.ref.WeakReference
|
|
import java.net.InetSocketAddress
|
|
import java.net.Socket
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
import java.util.concurrent.TimeUnit
|
|
import kotlin.coroutines.cancellation.CancellationException
|
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.cancel
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.launch
|
|
|
|
class Amber : Application(), LifecycleObserver {
|
|
private var mainActivityRef: WeakReference<MainActivity?>? = null
|
|
val crashReportCache: CrashReportCache by lazy { CrashReportCache(this.applicationContext) }
|
|
|
|
fun setMainActivity(activity: MainActivity?) {
|
|
Log.d(TAG, "Setting main activity ref to $activity")
|
|
mainActivityRef = WeakReference<MainActivity?>(activity)
|
|
}
|
|
|
|
fun getMainActivity(): MainActivity? {
|
|
return if (mainActivityRef != null) mainActivityRef!!.get() else null
|
|
}
|
|
|
|
// Exists to avoid exceptions stopping the coroutine
|
|
val exceptionHandler =
|
|
CoroutineExceptionHandler { _, throwable ->
|
|
Log.e("AmberCoroutine", "Caught exception: ${throwable.message}", throwable)
|
|
if (throwable is FailedMigrationException) throw throwable
|
|
}
|
|
|
|
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
|
|
|
var settings: AmberSettings = AmberSettings()
|
|
|
|
val factory = OkHttpWebSocket.Builder { url ->
|
|
val useProxy = if (isPrivateIp(url.url)) false else settings.useProxy
|
|
HttpClientManager.getHttpClient(useProxy)
|
|
}
|
|
|
|
val client: NostrClient = NostrClient(factory, applicationIOScope)
|
|
|
|
val stats = AmberRelayStats(client, applicationIOScope, this)
|
|
|
|
// logs and stat counts.
|
|
val listener = NostrClientLoggerListener(this, stats, applicationIOScope).also {
|
|
client.subscribe(it)
|
|
}
|
|
|
|
// Authenticates with relays.
|
|
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { event ->
|
|
LocalPreferences.allAccounts(this).map { account ->
|
|
account.signer.signerSync.sign(event)
|
|
}
|
|
}
|
|
|
|
val relayStats = RelayStats(client)
|
|
|
|
// TODO: I assume you want this sub to stay active in the background
|
|
// TODO: Find a way to call either client.reconnect or notificationSubscription.updateFilters() to
|
|
// keep the filter live when the relay inevitably disconnects. NostrClient doesn't try to reconnect
|
|
// unless the filter is refreshed or a new event is sent.
|
|
val notificationSubscription = NotificationSubscription(client, this)
|
|
|
|
// This runs on the foreground only
|
|
val profileSubscription = ProfileSubscription(client, this, applicationIOScope)
|
|
|
|
private var databases = ConcurrentHashMap<String, AppDatabase>()
|
|
private var logDatabases = ConcurrentHashMap<String, LogDatabase>()
|
|
private var historyDatabases = ConcurrentHashMap<String, HistoryDatabase>()
|
|
|
|
val isOnMobileDataState = mutableStateOf(false)
|
|
val isOnWifiDataState = mutableStateOf(false)
|
|
val isOnOfflineState = mutableStateOf(false)
|
|
private val isStartingApp = MutableStateFlow(false)
|
|
val isStartingAppState = isStartingApp
|
|
val notificationCache = LruCache<String, Long>(10)
|
|
|
|
fun isSocksProxyAlive(proxyHost: String, proxyPort: Int): Boolean {
|
|
try {
|
|
val socket = Socket()
|
|
socket.connect(InetSocketAddress(proxyHost, proxyPort), 5000) // 3-second timeout
|
|
socket.close()
|
|
return true
|
|
} catch (e: Exception) {
|
|
if (e.message?.contains("EACCES (Permission denied)") == true) {
|
|
AmberListenerSingleton.accountStateViewModel?.toast(getString(R.string.warning), getString(R.string.network_permission_message))
|
|
} else if (e.message?.contains("socket failed: EPERM (Operation not permitted)") == true) {
|
|
AmberListenerSingleton.accountStateViewModel?.toast(getString(R.string.warning), getString(R.string.network_permission_message))
|
|
}
|
|
Log.e(TAG, "Failed to connect to proxy", e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities?): Boolean {
|
|
val isOnMobileData = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
|
val isOnWifi = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
|
val isOffline = !isOnMobileData && !isOnWifi
|
|
|
|
var changedNetwork = false
|
|
|
|
if (isOnMobileDataState.value != isOnMobileData) {
|
|
isOnMobileDataState.value = isOnMobileData
|
|
|
|
changedNetwork = true
|
|
}
|
|
|
|
if (isOnWifiDataState.value != isOnWifi) {
|
|
isOnWifiDataState.value = isOnWifi
|
|
|
|
changedNetwork = true
|
|
}
|
|
|
|
if (isOnOfflineState.value != isOffline) {
|
|
isOnOfflineState.value = isOffline
|
|
changedNetwork = true
|
|
}
|
|
|
|
if (changedNetwork) {
|
|
if (isOnMobileData) {
|
|
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE)
|
|
} else {
|
|
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI)
|
|
}
|
|
}
|
|
|
|
return changedNetwork
|
|
}
|
|
|
|
private fun startCleanLogsAlarm() {
|
|
val workRequest = PeriodicWorkRequestBuilder<ClearLogsWorker>(
|
|
24,
|
|
TimeUnit.HOURS,
|
|
)
|
|
.setInitialDelay(5, TimeUnit.MINUTES) // Delay first run by 5 minutes
|
|
.addTag("clearLogsWork")
|
|
.build()
|
|
|
|
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
|
"ClearLogsWorker",
|
|
ExistingPeriodicWorkPolicy.REPLACE,
|
|
workRequest,
|
|
)
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
|
|
isStartingAppState.value = true
|
|
isStartingApp.value = true
|
|
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
|
|
|
|
Log.d(TAG, "onCreate Amber")
|
|
|
|
startCleanLogsAlarm()
|
|
|
|
HttpClientManager.setDefaultUserAgent("Amber/${BuildConfig.VERSION_NAME}")
|
|
_instance = this
|
|
|
|
LocalPreferences.allSavedAccounts(this).forEach {
|
|
databases[it.npub] = getDatabase(it.npub)
|
|
applicationIOScope.launch {
|
|
databases[it.npub]?.dao()?.getAllNotConnected()?.forEach { app ->
|
|
if (app.application.secret.isNotEmpty() && app.application.secret != app.application.key) {
|
|
app.application.isConnected = true
|
|
databases[it.npub]?.dao()?.insertApplicationWithPermissions(app)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
runMigrations()
|
|
}
|
|
|
|
fun runMigrations() {
|
|
applicationIOScope.launch {
|
|
try {
|
|
LocalPreferences.allSavedAccounts(this@Amber).forEach {
|
|
if (LocalPreferences.didMigrateFromLegacyStorage(this@Amber, it.npub)) {
|
|
LocalPreferences.deleteLegacyUserPreferenceFile(this@Amber, it.npub)
|
|
}
|
|
}
|
|
if (LocalPreferences.existsLegacySettings(this@Amber)) {
|
|
var error = false
|
|
for (accountInfo in LocalPreferences.allLegacySavedAccounts(this@Amber)) {
|
|
try {
|
|
LocalPreferences.migrateFromSharedPrefs(this@Amber, accountInfo.npub)
|
|
LocalPreferences.loadFromEncryptedStorage(this@Amber, accountInfo.npub)
|
|
} catch (e: Exception) {
|
|
error = true
|
|
Log.e(TAG, "Failed to migrate settings for account ${accountInfo.npub}", e)
|
|
}
|
|
}
|
|
if (!error) {
|
|
LocalPreferences.migrateSettings(this@Amber)
|
|
LocalPreferences.deleteSettingsPreferenceFile(this@Amber)
|
|
} else {
|
|
throw FailedMigrationException("Failed to migrate settings for all accounts")
|
|
}
|
|
}
|
|
LocalPreferences.migrateTorSettings(this@Amber)
|
|
val currentAccount = LocalPreferences.currentAccount(this@Amber)
|
|
if (currentAccount.isNullOrBlank() && LocalPreferences.allSavedAccounts(this@Amber).isNotEmpty()) {
|
|
LocalPreferences.switchToAccount(this@Amber, LocalPreferences.allSavedAccounts(this@Amber).first().npub)
|
|
}
|
|
settings = LocalPreferences.loadSettingsFromEncryptedStorage()
|
|
settings.language?.let {
|
|
AppCompatDelegate.setApplicationLocales(
|
|
LocaleListCompat.forLanguageTags(it),
|
|
)
|
|
}
|
|
LocalPreferences.reloadApp()
|
|
fixRejectedPermissions()
|
|
fixAcceptedPermissions()
|
|
|
|
checkForNewRelaysAndUpdateAllFilters(true)
|
|
if (settings.killSwitch.value) {
|
|
client.disconnect()
|
|
}
|
|
|
|
launch(Dispatchers.Main) {
|
|
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
|
override fun onStart(owner: LifecycleOwner) {
|
|
Log.d("ProcessLifecycleOwner", "App in foreground")
|
|
isAppInForeground = true
|
|
|
|
// activates the profile filter only when the app is in the foreground
|
|
if (!settings.killSwitch.value) {
|
|
applicationIOScope.launch {
|
|
profileSubscription.updateFilter()
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onStop(owner: LifecycleOwner) {
|
|
Log.d("ProcessLifecycleOwner", "App in background")
|
|
isAppInForeground = false
|
|
|
|
// closes the filter when in the background
|
|
applicationIOScope.launch {
|
|
profileSubscription.closeSub()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
isStartingApp.value = false
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to run migrations", e)
|
|
isStartingApp.value = false
|
|
if (e is CancellationException) throw e
|
|
if (e is FailedMigrationException) throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun fixRejectedPermissions() {
|
|
LocalPreferences.allSavedAccounts(this).forEach { account ->
|
|
val permissions = getDatabase(account.npub).dao().getAllRejectedPermissions()
|
|
val localPermissions =
|
|
permissions.map {
|
|
val isAcceptable = it.acceptable
|
|
val acceptUntil = if (isAcceptable) Long.MAX_VALUE / 1000 else 0L
|
|
val rejectUntil = if (!isAcceptable) Long.MAX_VALUE / 1000 else 0L
|
|
|
|
it.copy(
|
|
acceptable = it.acceptable,
|
|
acceptUntil = acceptUntil,
|
|
rejectUntil = rejectUntil,
|
|
rememberType = RememberType.ALWAYS.screenCode,
|
|
)
|
|
}
|
|
getDatabase(account.npub).dao().insertPermissions(localPermissions)
|
|
}
|
|
}
|
|
|
|
suspend fun fixAcceptedPermissions() {
|
|
LocalPreferences.allSavedAccounts(this).forEach { account ->
|
|
val permissions = getDatabase(account.npub).dao().getAllAcceptedPermissions()
|
|
Log.d(TAG, "Found ${permissions.size} accepted permissions")
|
|
val localPermissions =
|
|
permissions.map {
|
|
val isAcceptable = it.acceptable
|
|
val acceptUntil = if (isAcceptable) Long.MAX_VALUE / 1000 else 0L
|
|
val rejectUntil = if (!isAcceptable) Long.MAX_VALUE / 1000 else 0L
|
|
|
|
it.copy(
|
|
acceptable = it.acceptable,
|
|
acceptUntil = acceptUntil,
|
|
rejectUntil = rejectUntil,
|
|
rememberType = RememberType.ALWAYS.screenCode,
|
|
)
|
|
}
|
|
getDatabase(account.npub).dao().insertPermissions(localPermissions)
|
|
}
|
|
}
|
|
|
|
fun startService() {
|
|
try {
|
|
Log.d(TAG, "Starting ConnectivityService")
|
|
val serviceIntent = Intent(this, ConnectivityService::class.java)
|
|
this.startForegroundService(serviceIntent)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to start ConnectivityService", e)
|
|
if (e is CancellationException) throw e
|
|
}
|
|
}
|
|
|
|
fun reconnect() {
|
|
if (settings.killSwitch.value) {
|
|
return
|
|
}
|
|
Log.d(TAG, "reconnecting relays")
|
|
val wasActive = client.isActive()
|
|
if (!wasActive) {
|
|
client.connect()
|
|
}
|
|
client.reconnect(wasActive)
|
|
stats.updateNotification()
|
|
}
|
|
|
|
fun getDatabase(npub: String): AppDatabase {
|
|
if (!databases.containsKey(npub)) {
|
|
databases[npub] = AppDatabase.getDatabase(this, npub)
|
|
}
|
|
return databases[npub]!!
|
|
}
|
|
|
|
fun getLogDatabase(npub: String): LogDatabase {
|
|
if (!logDatabases.containsKey(npub)) {
|
|
logDatabases[npub] = LogDatabase.getDatabase(this, npub)
|
|
}
|
|
return logDatabases[npub]!!
|
|
}
|
|
|
|
fun getHistoryDatabase(npub: String): HistoryDatabase {
|
|
if (!historyDatabases.containsKey(npub)) {
|
|
historyDatabases[npub] = HistoryDatabase.getDatabase(this, npub)
|
|
}
|
|
return historyDatabases[npub]!!
|
|
}
|
|
|
|
fun getSavedRelays(account: Account): Set<NormalizedRelayUrl> {
|
|
val savedRelays = mutableSetOf<NormalizedRelayUrl>()
|
|
val database = getDatabase(account.npub)
|
|
database.dao().getAllApplications().forEach {
|
|
it.application.relays.forEach { setupInfo ->
|
|
savedRelays.add(setupInfo)
|
|
}
|
|
}
|
|
|
|
savedRelays.addAll(settings.defaultRelays)
|
|
|
|
return savedRelays
|
|
}
|
|
|
|
suspend fun checkForNewRelaysAndUpdateAllFilters(
|
|
shouldReconnect: Boolean = false,
|
|
) {
|
|
if (settings.killSwitch.value) {
|
|
client.disconnect()
|
|
return
|
|
}
|
|
|
|
val wasActive = client.isActive()
|
|
|
|
@Suppress("KotlinConstantConditions")
|
|
// TODO: You can filter inside each update filter for only
|
|
// localhost relays and keep these alive even on the offline
|
|
// mode
|
|
if (BuildConfig.FLAVOR != "offline") {
|
|
// these update the relay list in the filters and send them to the
|
|
// relay, reconnecting if needed
|
|
if (isAppInForeground) {
|
|
profileSubscription.updateFilter()
|
|
}
|
|
notificationSubscription.updateFilter()
|
|
}
|
|
Log.d(TAG, "checkForNewRelaysAndUpdateAllFilters wasActive: $wasActive")
|
|
if (!wasActive) {
|
|
delay(3000)
|
|
client.connect()
|
|
}
|
|
|
|
if (shouldReconnect) {
|
|
client.reconnect(wasActive)
|
|
}
|
|
}
|
|
|
|
fun isPrivateIp(url: String): Boolean {
|
|
return url.contains("127.0.0.1") ||
|
|
url.contains("localhost") ||
|
|
url.contains("192.168.") ||
|
|
url.contains("172.16.") ||
|
|
url.contains("172.17.") ||
|
|
url.contains("172.18.") ||
|
|
url.contains("172.19.") ||
|
|
url.contains("172.20.") ||
|
|
url.contains("172.21.") ||
|
|
url.contains("172.22.") ||
|
|
url.contains("172.23.") ||
|
|
url.contains("172.24.") ||
|
|
url.contains("172.25.") ||
|
|
url.contains("172.26.") ||
|
|
url.contains("172.27.") ||
|
|
url.contains("172.28.") ||
|
|
url.contains("172.29.") ||
|
|
url.contains("172.30.") ||
|
|
url.contains("172.31.")
|
|
}
|
|
|
|
override fun onTerminate() {
|
|
super.onTerminate()
|
|
applicationIOScope.cancel()
|
|
}
|
|
|
|
suspend fun sendFeedBack(
|
|
subject: String,
|
|
body: String,
|
|
type: FeedbackType,
|
|
account: Account,
|
|
): Boolean {
|
|
val relays = setOfNotNull(
|
|
RelayUrlNormalizer.normalizeOrNull("wss://nos.lol/"),
|
|
RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io/"),
|
|
)
|
|
|
|
val repositoryEvent = GitRepositoryEvent(
|
|
"",
|
|
"7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19",
|
|
TimeUtils.now(),
|
|
tags = arrayOf(arrayOf("d", "Amber")),
|
|
"",
|
|
"",
|
|
)
|
|
|
|
val template = GitIssueEvent.build(
|
|
subject,
|
|
body,
|
|
EventHintBundle(repositoryEvent),
|
|
listOf(PTag("7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19", null)),
|
|
listOf(if (type == FeedbackType.BUG_REPORT) "bug" else "enhancement"),
|
|
)
|
|
|
|
val event = account.signer.signerSync.sign(
|
|
template,
|
|
)
|
|
|
|
// This will automatically connect to these relays even if they are not in the list
|
|
// once the events have been saved, it will disconnect from them unless there are other
|
|
// filters with them
|
|
return client.sendAndWaitForResponse(event, relays)
|
|
}
|
|
|
|
companion object {
|
|
var isAppInForeground = false
|
|
const val TAG = "Amber"
|
|
|
|
@Volatile
|
|
private var _instance: Amber? = null
|
|
val instance: Amber get() =
|
|
_instance ?: synchronized(this) {
|
|
_instance ?: Amber().also { _instance = it }
|
|
}
|
|
}
|
|
}
|
|
|
|
class FailedMigrationException(msg: String) : Exception(msg)
|