diff --git a/.github/workflows/nyms5-android-build.yml b/.github/workflows/nyms5-android-build.yml index e85d9c67ad..ca8179eb16 100644 --- a/.github/workflows/nyms5-android-build.yml +++ b/.github/workflows/nyms5-android-build.yml @@ -68,8 +68,10 @@ jobs: working-directory: nym-connect/native/android env: ANDROID_SDK_ROOT: ${{ env.ANDROID_HOME }} + SENTRY_AUTH_TOKEN: ${{ secrets.NYMS5_ANDROID_SENTRY_AUTH_TOKEN }} # build for arm64 and x86_64 run: | + echo "auth.token=$SENTRY_AUTH_TOKEN" | tee -a sentry.properties ./gradlew :app:assembleArch64Debug ./gradlew :app:assembleArch64Release @@ -107,4 +109,3 @@ jobs: files: | apk/nyms5-arch64-debug.apk apk/nyms5-arch64-release.apk - diff --git a/nym-connect/native/android/app/build.gradle b/nym-connect/native/android/app/build.gradle index 1afc11b2be..23d75605c4 100644 --- a/nym-connect/native/android/app/build.gradle +++ b/nym-connect/native/android/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' + id "io.sentry.android.gradle" version "3.11.0" } android { @@ -101,18 +102,20 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation 'androidx.activity:activity-compose:1.5.1' + implementation 'androidx.activity:activity-compose:1.7.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0' implementation platform('androidx.compose:compose-bom:2022.10.00') implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1' + implementation 'androidx.navigation:navigation-compose:2.6.0' implementation 'androidx.compose.runtime:runtime-livedata' implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.material3:material3' implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'androidx.datastore:datastore-preferences:1.0.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/nym-connect/native/android/app/src/main/AndroidManifest.xml b/nym-connect/native/android/app/src/main/AndroidManifest.xml index 6321a2ce8c..d013b90324 100644 --- a/nym-connect/native/android/app/src/main/AndroidManifest.xml +++ b/nym-connect/native/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Nyms5" + android:enableOnBackInvokedCallback="true" tools:targetApi="31"> + + + + + + + + diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/App.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/App.kt index 08c075a460..8a59ada991 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/App.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/App.kt @@ -1,9 +1,21 @@ package net.nymtech.nyms5 import android.app.Application +import android.content.Context import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import androidx.work.Configuration import androidx.work.DelegatingWorkerFactory +import io.sentry.android.core.SentryAndroid +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +val monitoringKey = booleanPreferencesKey("monitoring") class App : Application(), Configuration.Provider { companion object { @@ -13,6 +25,30 @@ class App : Application(), Configuration.Provider { private val tag = "App" + override fun onCreate() { + super.onCreate() + val app = this + + runBlocking { + val monitoring = applicationContext.dataStore.data.map { preferences -> + preferences[monitoringKey] ?: false + }.first() + + if (monitoring) { + Log.i(tag, "Performance monitoring and error reporting enabled") + SentryAndroid.init(app) { options -> + options.dsn = + "https://6872f5818bc147ef9c0fce114fcaac8a@o967446.ingest.sentry.io/4505306218102784" + options.enableAllAutoBreadcrumbs(true) + + // TODO should be adjusted in production env + options.tracesSampleRate = 1.0 + options.profilesSampleRate = 1.0 + } + } + } + } + override fun getWorkManagerConfiguration(): Configuration { val workerFactory = DelegatingWorkerFactory() // pass in the NymProxy class instance diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt index 260a847e20..6e5a472be5 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainActivity.kt @@ -14,11 +14,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -34,6 +44,8 @@ import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import net.nymtech.nyms5.ui.theme.NymTheme import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color @@ -42,8 +54,17 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController import androidx.work.WorkInfo import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map class MainActivity : ComponentActivity() { private val tag = "MainActivity" @@ -81,18 +102,16 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - Log.d(tag, "____uiState collect") - viewModel.uiState.collect { - setContent { - NymTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { + Log.d(tag, "____UI recompose") + applicationContext.dataStore.data.map { preferences -> + preferences[monitoringKey] ?: false + }.collect { monitoring -> + viewModel.uiState.collect { + setContent { + NymTheme { val loading = it.loading - S5ClientSwitch(it.connected, loading, { + HomeScreen(it, monitoring, applicationContext.dataStore) { if (!loading) { when { it -> { @@ -106,7 +125,7 @@ class MainActivity : ComponentActivity() { } } } - }) + } } } } @@ -121,6 +140,85 @@ class MainActivity : ComponentActivity() { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + proxyState: MainViewModel.ProxyState, + monitoring: Boolean, + dataStore: DataStore, + onSwitch: (value: Boolean) -> Unit, +) { + val navController = rememberNavController() + var expanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Scaffold(topBar = { + CenterAlignedTopAppBar( + title = { + Text(stringResource(R.string.app_name)) + }, + navigationIcon = { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + if (currentRoute === "proxy") { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Main menu" + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem(onClick = { + navController.navigate("monitoring") { + popUpTo("proxy") + } + expanded = false + }, text = { + Text("Error reporting") + }) + } + } else { + IconButton(onClick = { + navController.navigate("proxy") { + popUpTo("proxy") + } + }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back home" + ) + } + } + }, + ) + }) { contentPadding -> + NavHost( + navController = navController, + startDestination = "proxy", + modifier = Modifier.padding(contentPadding) + ) { + composable("proxy") { + S5ClientSwitch( + connected = proxyState.connected, + loading = proxyState.loading, + onSwitch = onSwitch + ) + } + composable("monitoring") { + Monitoring(initialValue = monitoring) { + scope.launch(Dispatchers.IO) { + dataStore.edit { settings -> settings[monitoringKey] = it } + } + } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun S5ClientSwitch( @@ -203,6 +301,46 @@ fun S5ClientSwitch( } } +@Composable +fun Monitoring( + modifier: Modifier = Modifier, + initialValue: Boolean, + onSwitch: (value: Boolean) -> Unit, +) { + var monitoring by remember { mutableStateOf(initialValue) } + + Column( + modifier = modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text("Enable error reporting") + Spacer(modifier = modifier.width(16.dp)) + Switch(checked = monitoring, onCheckedChange = { + monitoring = it + onSwitch(it) + }) + } + Spacer(modifier = modifier.height(18.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.warning_24), + contentDescription = "copy to clipboard", + tint = Color.Yellow + ) + Spacer(modifier = modifier.width(16.dp)) + Text(stringResource(R.string.monitoring_desc_3), color = Color.Yellow) + } + Spacer(modifier = modifier.height(18.dp)) + Text(stringResource(R.string.monitoring_desc_1)) + Spacer(modifier = modifier.height(18.dp)) + Text(stringResource(R.string.monitoring_desc_2)) + } +} + @Preview @Composable fun PreviewSocks5Client() { @@ -225,3 +363,18 @@ fun PreviewSocks5Client() { } } } + +@Preview +@Composable +fun PreviewMonitoring() { + NymTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Monitoring(initialValue = false) { + Log.d("Monitoring", "switch $it") + } + } + } +} diff --git a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt index 3a129daef4..f100dc7676 100644 --- a/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt +++ b/nym-connect/native/android/app/src/main/java/net/nymtech/nyms5/MainViewModel.kt @@ -27,7 +27,7 @@ class MainViewModel( private val workManager: WorkManager, private val nymProxy: NymProxy ) : ViewModel() { - private val tag = "viewModel" + private val tag = "MainViewModel" private val workRequest: OneTimeWorkRequest = OneTimeWorkRequestBuilder() diff --git a/nym-connect/native/android/app/src/main/res/drawable/warning_24.xml b/nym-connect/native/android/app/src/main/res/drawable/warning_24.xml new file mode 100644 index 0000000000..b1726a3610 --- /dev/null +++ b/nym-connect/native/android/app/src/main/res/drawable/warning_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/nym-connect/native/android/app/src/main/res/values/strings.xml b/nym-connect/native/android/app/src/main/res/values/strings.xml index 5a93a240ab..ee21a78dc9 100644 --- a/nym-connect/native/android/app/src/main/res/values/strings.xml +++ b/nym-connect/native/android/app/src/main/res/values/strings.xml @@ -11,4 +11,8 @@ https://harbourmaster.nymtech.net/v1/services?size=100 DpB3cHAchJiNBQi5FrZx2csXb1mrHkpYh9Wzf8Rjsuko.ANNWrvHqMYuertHGHUrZdBntQhpzfbWekB39qez9U2Vx@2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh Connected to the mixnet + Help Nym developers to fix errors, crashes and improve the application by enabling this option. If errors occur or if the app crashes, it will automatically send a report. Also it tracks various performance metrics. We use sentry.io service to handle this. + Note: A report can include your external IP, this can be useful to catch issues related to IP location. + All recorded data is used by Nym developers and for app development purposes only. + You must restart the application for the change to take effect. \ No newline at end of file diff --git a/nym-connect/native/android/sentry.properties b/nym-connect/native/android/sentry.properties new file mode 100644 index 0000000000..f2c03276a0 --- /dev/null +++ b/nym-connect/native/android/sentry.properties @@ -0,0 +1,3 @@ +defaults.project=nym-connect-android +defaults.org=nymtech +# auth.token=xxx