diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e647da3b..4ad932d4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,7 +11,7 @@ android { defaultConfig { applicationId = "com.robosats" - minSdk = 24 + minSdk = 26 targetSdk = 36 versionCode = 15 versionName = "0.8.1-alpha" @@ -72,6 +72,8 @@ dependencies { implementation(libs.material) implementation(libs.okhttp) implementation(libs.kmp.tor) + implementation(libs.quartz) + implementation(libs.ammolite) // Add the KMP Tor binary dependency (contains the native .so files) implementation(libs.kmp.tor.binary) implementation(libs.androidx.activity) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c3fca3fe..b7211d09 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ + + + + + + + diff --git a/android/app/src/main/java/com/robosats/Connectivity.kt b/android/app/src/main/java/com/robosats/Connectivity.kt new file mode 100644 index 00000000..a8401b5a --- /dev/null +++ b/android/app/src/main/java/com/robosats/Connectivity.kt @@ -0,0 +1,38 @@ +package com.robosats + +import android.net.NetworkCapabilities +import com.vitorpamplona.ammolite.service.HttpClientManager + +class Connectivity { + companion object { + var isOnMobileData: Boolean = false + var isOnWifiData: Boolean = false + + fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { + val isOnMobileDataNet = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + val isOnWifiNet = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + + var changedNetwork = false + + if (isOnMobileData != isOnMobileDataNet) { + isOnMobileData = isOnMobileDataNet + changedNetwork = true + } + + if (isOnWifiData != isOnWifiNet) { + isOnWifiData = isOnWifiNet + changedNetwork = true + } + + if (changedNetwork) { + if (isOnMobileDataNet) { + HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE) + } else { + HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI) + } + } + + return changedNetwork + } + } +} diff --git a/android/app/src/main/java/com/robosats/MainActivity.kt b/android/app/src/main/java/com/robosats/MainActivity.kt index 3fdc0cfd..1355b301 100644 --- a/android/app/src/main/java/com/robosats/MainActivity.kt +++ b/android/app/src/main/java/com/robosats/MainActivity.kt @@ -1,5 +1,6 @@ package com.robosats +import android.Manifest import android.annotation.SuppressLint import android.app.Application import android.content.pm.ActivityInfo @@ -20,14 +21,19 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.robosats.services.NotificationsService import com.robosats.tor.TorKmp import com.robosats.tor.TorKmpManager class MainActivity : AppCompatActivity() { + private val requestCodePostNotifications: Int = 1 private lateinit var webView: WebView private lateinit var torKmp: TorKmp private lateinit var loadingContainer: ConstraintLayout @@ -52,6 +58,28 @@ class MainActivity : AppCompatActivity() { // Initialize Tor and setup WebView only after Tor is properly connected initializeTor() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + requestCodePostNotifications, + ) + } + } + + private fun initializeNotifications() { + startForegroundService( + Intent( + this, + NotificationsService::class.java, + ), + ) } private fun initializeTor() { @@ -255,6 +283,8 @@ class MainActivity : AppCompatActivity() { // Now it's safe to load the local HTML file webView.loadUrl("file:///android_asset/index.html") + + initializeNotifications() } } catch (e: Exception) { Log.e("WebViewSetup", "Security error in WebView setup: ${e.message}", e) diff --git a/android/app/src/main/java/com/robosats/models/NostrClient.kt b/android/app/src/main/java/com/robosats/models/NostrClient.kt new file mode 100644 index 00000000..f7d39ae3 --- /dev/null +++ b/android/app/src/main/java/com/robosats/models/NostrClient.kt @@ -0,0 +1,80 @@ +package com.robosats.models + +import android.util.Log +import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES +import com.vitorpamplona.ammolite.relays.Client +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.ammolite.relays.RelayPool +import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter + +object NostrClient { + private var subscriptionNotificationId = "robosatsNotificationId" + + fun init() { + RelayPool.register(Client) + } + + fun stop() { + RelayPool.unloadRelays() + } + + fun start() { + connectRelays() + subscribeToInbox() + } + + fun checkRelaysHealth() { + if (RelayPool.getAll().isEmpty()) { + stop() + start() + } + RelayPool.getAll().forEach { + if (!it.isConnected()) { + Log.d( + "RobosatsNostrClient", + "Relay ${it.url} is not connected, reconnecting...", + ) + it.connectAndSendFiltersIfDisconnected() + } + } + } + + private fun connectRelays() { + val relays = emptyList() + + relays.forEach { + Client.sendFilterOnlyIfDisconnected() + if (RelayPool.getRelays(it).isEmpty()) { + RelayPool.addRelay( + Relay( + it, + read = true, + write = false, + forceProxy = false, + activeTypes = COMMON_FEED_TYPES, + ), + ) + } + } + } + + private fun subscribeToInbox() { + val authors = emptyList() + + if (authors.isNotEmpty()) { + Client.sendFilter( + subscriptionNotificationId, + listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = SincePerRelayFilter( + kinds = listOf(1, 4, 6, 7, 9735), + tags = mapOf("p" to authors), + ), + ), + ), + ) + } + } +} diff --git a/android/app/src/main/java/com/robosats/services/NotificationsService.kt b/android/app/src/main/java/com/robosats/services/NotificationsService.kt new file mode 100644 index 00000000..9324e865 --- /dev/null +++ b/android/app/src/main/java/com/robosats/services/NotificationsService.kt @@ -0,0 +1,214 @@ +package com.robosats.services +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.IBinder +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.robosats.Connectivity +import com.robosats.R +import com.robosats.models.NostrClient +import com.vitorpamplona.ammolite.relays.Client +import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.events.Event +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Timer +import java.util.TimerTask +import java.util.concurrent.ConcurrentHashMap + +class NotificationsService : Service() { + private var channelRelaysId = "RelaysConnections" + private var channelNotificationsId = "Notifications" + + private lateinit var notificationGroup: NotificationChannelGroupCompat + + private val timer = Timer() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val processedEvents = ConcurrentHashMap() + + private val clientNotificationListener = + object : Client.Listener { + override fun onEvent( + event: Event, + subscriptionId: String, + relay: Relay, + afterEOSE: Boolean, + ) { + if (processedEvents.putIfAbsent(event.id, true) == null) { + Log.d("RobosatsNotifications", "Relay Event: ${relay.url} - $subscriptionId - ${event.toJson()}") + val notify = true + + if (notify) { + Log.d("RobosatsNotifications", "Relay Event: ${relay.url} - $subscriptionId - Broadcast") + } + } + } + } + + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + var lastNetwork: Network? = null + + override fun onAvailable(network: Network) { + super.onAvailable(network) + + if (lastNetwork != null && lastNetwork != network) { + scope.launch(Dispatchers.IO) { + stopSubscription() + delay(1000) + startSubscription() + } + } + + lastNetwork = network + } + + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + scope.launch(Dispatchers.IO) { + Log.d( + "RobosatsNotifications", + "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${Connectivity.isOnMobileData} hasWifi ${Connectivity.isOnWifiData}", + ) + if (Connectivity.updateNetworkCapabilities(networkCapabilities)) { + stopSubscription() + delay(1000) + startSubscription() + } + } + } + } + + override fun onBind(intent: Intent): IBinder { + return null!! + } + + override fun onCreate() { + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.registerDefaultNetworkCallback(networkCallback) + NostrClient.init() + super.onCreate() + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startService() + + return START_STICKY + } + + override fun onDestroy() { + timer.cancel() + stopSubscription() + + try { + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (e: Exception) { + Log.d("RobosatsNotifications", "Failed to unregisterNetworkCallback", e) + } + + super.onDestroy() + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun startService() { + try { + Log.d("RobosatsNotifications", "Starting foreground service...") + startForeground(1, createNotification()) + keepAlive() + + startSubscription() + + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } catch (e: Exception) { + Log.e("NotificationsService", "Error in service", e) + } + } + + private fun startSubscription() { + if (!Client.isSubscribed(clientNotificationListener)) Client.subscribe(clientNotificationListener) + + CoroutineScope(Dispatchers.IO).launch { + NostrClient.start() + } + } + + private fun stopSubscription() { + Client.unsubscribe(clientNotificationListener) + NostrClient.stop() + } + + private fun keepAlive() { + timer.schedule( + object : TimerTask() { + override fun run() { + NostrClient.checkRelaysHealth() + } + }, + 5000, + 61000, + ) + } + + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun createNotification(): Notification { + val notificationManager = NotificationManagerCompat.from(this) + + Log.d("RobosatsNotifications", "Building groups...") + notificationGroup = NotificationChannelGroupCompat.Builder("ServiceGroup") + .setName(getString(R.string.notifications)) + .setDescription(getString(R.string.robosats_is_running_in_background)) + .build() + + notificationManager.createNotificationChannelGroup(notificationGroup) + + Log.d("RobosatsNotifications", "Building channels...") + val channelRelays = NotificationChannelCompat.Builder(channelRelaysId, NotificationManager.IMPORTANCE_DEFAULT) + .setName(getString(R.string.service)) + .setGroup(notificationGroup.id) + .build() + + val channelNotification = NotificationChannelCompat.Builder(channelNotificationsId, NotificationManager.IMPORTANCE_HIGH) + .setName(getString(R.string.notifications)) + .setGroup(notificationGroup.id) + .build() + + notificationManager.createNotificationChannel(channelRelays) + notificationManager.createNotificationChannel(channelNotification) + + Log.d("RobosatsNotifications", "Building notification...") + val notificationBuilder = + NotificationCompat.Builder(this, channelRelaysId) + .setContentTitle(getString(R.string.robosats_is_running_in_background)) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setGroup(notificationGroup.id) + .setSmallIcon(R.drawable.ic_notification) + + val build = notificationBuilder.build() + notificationManager.notify(1, build) + return build + } +} diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..cff1325b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d9f62954..79f984aa 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ Robosats + Background Service + Connection + Configuration + Robosats is running in background fetching for notifications + Notifications diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b48b2c68..66f5daa1 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ constraintlayout = "2.2.1" kmpTor= "4.8.10-0-1.4.5" kmpTorBinary= "4.8.10-0" okhttp = "5.0.0-alpha.14" +quartz = "0.92.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -25,6 +26,8 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const kmp-tor = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor", version.ref = "kmpTor" } kmp-tor-binary = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor-binary-extract-android", version.ref = "kmpTorBinary" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +quartz = { module = "com.github.vitorpamplona.amethyst:quartz", version.ref = "quartz" } +ammolite = { module = "com.github.vitorpamplona.amethyst:ammolite", version.ref = "quartz" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e9d22a78..336249ec 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -3,16 +3,17 @@ pluginManagement { google () mavenCentral() gradlePluginPortal() - jcenter() maven("https://mvnrepository.com") + maven("https://jitpack.io") } } dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() - jcenter() maven("https://mvnrepository.com") + maven("https://jitpack.io") } } diff --git a/frontend/src/basic/RobotPage/index.tsx b/frontend/src/basic/RobotPage/index.tsx index d4d96298..b8905cbb 100644 --- a/frontend/src/basic/RobotPage/index.tsx +++ b/frontend/src/basic/RobotPage/index.tsx @@ -19,7 +19,7 @@ const RobotPage = (): React.JSX.Element => { const [inputToken, setInputToken] = useState(''); const [view, setView] = useState<'welcome' | 'onboarding' | 'profile'>( - garage.currentSlot !== null ? 'profile' : 'welcome', + Object.keys(garage.slots).length > 0 ? 'profile' : 'welcome', ); useEffect(() => { @@ -30,7 +30,7 @@ const RobotPage = (): React.JSX.Element => { }, [torStatus, page, slotUpdatedAt]); useEffect(() => { - if (garage.currentSlot && view === 'welcome') setView('profile'); + if (Object.keys(garage.slots).length > 0 && view === 'welcome') setView('profile'); }, [garage.currentSlot]); return (