From 3829e9ce3f703b58dde6da26aea357ceb9d7fa75 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Thu, 1 Aug 2024 18:51:26 +0200 Subject: [PATCH] implement ssh button and misc improvements a lot for one commit but I didn't wanna commit every single line of code --- app/src/main/AndroidManifest.xml | 7 +- .../java/eu/m724/vastapp/activity/Opener.kt | 28 +++ .../vastapp/activity/PermissionChecker.kt | 117 +++++++------ .../activity/dashboard/DashboardActivity.kt | 4 +- .../activity/dashboard/DashboardViewModel.kt | 96 ++++++++++- .../activity/dashboard/screen/Dashboard.kt | 5 +- .../vastapp/activity/dashboard/screen/Help.kt | 7 +- .../activity/dashboard/screen/Instances.kt | 143 +++++++--------- .../vastapp/activity/login/LoginActivity.kt | 21 --- .../activity/termux/TermuxSshActivity.kt | 160 ++++++++++++++++++ .../vastapp/activity/termux/ui/theme/Color.kt | 11 ++ .../vastapp/activity/termux/ui/theme/Theme.kt | 58 +++++++ .../vastapp/activity/termux/ui/theme/Type.kt | 34 ++++ .../vastapp/vastai/data/RentedInstance.kt | 2 +- app/src/main/res/drawable/termux_icon.xml | 28 +++ app/src/main/res/values/strings.xml | 3 + 16 files changed, 562 insertions(+), 162 deletions(-) create mode 100644 app/src/main/java/eu/m724/vastapp/activity/Opener.kt create mode 100644 app/src/main/java/eu/m724/vastapp/activity/termux/TermuxSshActivity.kt create mode 100644 app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Color.kt create mode 100644 app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Theme.kt create mode 100644 app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/termux_icon.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a7a2e0..7065266 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,14 +8,19 @@ + Unit) { - val available = canAskForPermission(permission, activity) - - if (hasPermission(permission)) { - callback(true, false) - } else if (available) { // no permission but can request - requestPermission(permission, activity) { callback(it, true) } - } else { // no permission and can't request - callback(false, false) + /** + * check if a permission exists or if the app is installed + * @param permission the permission + * @param packageManager usually from application context + * @return if the permission exists + */ + fun permissionExists(permission: String, packageManager: PackageManager): Boolean { + try { + packageManager.getPermissionInfo(permission, 0) + return true + } catch (e: NameNotFoundException) { + return false + } } - } - // TODO should this be private? I mean it doesn't check for other stuff so it's a waste to register an activity if we don't have to - private fun requestPermission(permission: String, activity: ComponentActivity, callback: (Boolean) -> Unit) { - activity.registerForActivityResult( - ActivityResultContracts.RequestPermission(), - callback - ).launch(permission) + /** + * @param permission the permission + * @param activity the activity you're calling from + * @return if the permission can be asked for, that is if the user didn't check "don't ask again" + */ + fun canAskForPermission(permission: String, activity: ComponentActivity): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } + + /** + * request a permission if that permission is not granted + * @param permission the permission + * @param activity the activity you're calling from + * @param requestCode if set, calls requestPermissions, so the callback is not called and you must handle it in the activity + * @param callback an Unit, the first boolean is whether the permission is granted and the second one is whether we asked for it + */ + fun requestIfNoPermission(permission: String, activity: ComponentActivity, requestCode: Int? = null, callback: (Boolean, Boolean) -> Unit) { + val available = canAskForPermission(permission, activity) + + if (hasPermission(permission, activity.applicationContext)) { + callback(true, false) + } else if (available) { // no permission but can request + requestPermission(permission, activity, requestCode) { callback(it, true) } + } else { // no permission and can't request + callback(false, false) + } + } + + // TODO should this be private? I mean it doesn't check for other stuff so it's a waste to register an activity if we don't have to + private fun requestPermission(permission: String, activity: ComponentActivity, requestCode: Int? = null, callback: (Boolean) -> Unit) { + if (requestCode != null) { + activity.requestPermissions( + arrayOf(permission), + requestCode + ) + } else { + activity.registerForActivityResult( + ActivityResultContracts.RequestPermission(), + callback + ).launch(permission) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardActivity.kt b/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardActivity.kt index 8fa596f..fa209fe 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardActivity.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardActivity.kt @@ -49,13 +49,13 @@ class DashboardActivity : ComponentActivity() { val user = intent.getParcelableExtra("user")!! // TODO null check val executor = Executors.newSingleThreadExecutor() - val cronetEngine = CronetEngine.Builder(baseContext).build() + val cronetEngine = CronetEngine.Builder(baseContext).enableBrotli(true).build() val vastApi = VastApi(user.apiKey, cronetEngine, executor) // TODO use that from login activity val dashboardViewModel = DashboardViewModel(user, vastApi) lifecycleScope.launch { - dashboardViewModel.refresh() + dashboardViewModel.refresh(this@DashboardActivity) repeatOnLifecycle(Lifecycle.State.STARTED) { dashboardViewModel.refreshError.collect { it.forEach { errorMsg -> diff --git a/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardViewModel.kt b/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardViewModel.kt index 34b4ca9..c3ef0b3 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/dashboard/DashboardViewModel.kt @@ -1,6 +1,21 @@ package eu.m724.vastapp.activity.dashboard +import android.annotation.SuppressLint +import android.app.Application +import android.app.PendingIntent +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.m724.vastapp.R +import eu.m724.vastapp.activity.Opener +import eu.m724.vastapp.activity.PermissionChecker +import eu.m724.vastapp.activity.termux.TermuxSshActivity import eu.m724.vastapp.vastai.ApiRoute import eu.m724.vastapp.vastai.VastApi import eu.m724.vastapp.vastai.api.InstancesUrlRequestCallback @@ -13,7 +28,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : ViewModel() { // TODO do something with the user +class DashboardViewModel( + private val initialUser: User, + private val vastApi: VastApi +) : ViewModel() { // TODO do something with the user + private val _uiState: MutableStateFlow = MutableStateFlow(DashboardUiState(0)) val uiState: StateFlow = @@ -28,7 +47,10 @@ class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : View private val _refreshError: MutableStateFlow> = MutableStateFlow(emptyList()) val refreshError: StateFlow> = _refreshError.asStateFlow() - fun refresh() { + private val _termuxAvailable: MutableStateFlow = MutableStateFlow(0) + val termuxAvailable: StateFlow = _termuxAvailable.asStateFlow() + + fun refresh(activity: ComponentActivity) { _uiState.value = _uiState.value.copy(refreshing = 2) _refreshError.value = emptyList() @@ -65,7 +87,77 @@ class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : View userRequest.start() instancesRequest.start() + val context = activity.applicationContext + + _termuxAvailable.value = + if (PermissionChecker.permissionExists("com.termux.permission.RUN_COMMAND", context.packageManager)) { + if (PermissionChecker.hasPermission("com.termux.permission.RUN_COMMAND", context)) { + 1 // available and permitted + } else { + if (PermissionChecker.canAskForPermission("com.termux.permission.RUN_COMMAND", activity)) { + 0 // available but no permission + } else -1 // not available because permission denied + } + } else -1 // not available + // TODO I don't like this function especially the last line } + @SuppressLint("SdCardPath") + fun sshButtonClick(activity: ComponentActivity, rentedInstance: RentedInstance) { + val sshCommand = "ssh -p ${rentedInstance.sshProxyPort} root@${rentedInstance.sshProxyHost}" + val context = activity.applicationContext + + if (termuxAvailable.value > -1) { + PermissionChecker.requestIfNoPermission( + "com.termux.permission.RUN_COMMAND", + activity, 0 + ) { granted, asked -> + if (granted) { + val arguments = arrayOf( + "-p", rentedInstance.sshProxyPort.toString(), + "root@" + rentedInstance.sshProxyHost + ) + startTermux(context, arguments) + Thread.sleep(100) + println(activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + } else { + _termuxAvailable.value = -1 + copyToClipboard(context, sshCommand) + } + } + } else { + copyToClipboard(context, sshCommand) + } + } + + private fun copyToClipboard(context: Context, text: String) { + Toast.makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT + ).show() // TODO hide on a12 + + Opener.copyToClipboard(text, "ssh command", context) + } + + private fun startTermux(context: Context, arguments: Array) { + val noSshIntent = Intent(context, TermuxSshActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + 0, + noSshIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_MUTABLE + ) + + val intent = Intent() + intent.setClassName("com.termux", "com.termux.app.RunCommandService") + intent.setAction("com.termux.RUN_COMMAND") + intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/ssh") + intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", arguments) + intent.putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", pendingIntent) + + context.startForegroundService(intent) + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Dashboard.kt b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Dashboard.kt index 20a057a..3089dcf 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Dashboard.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Dashboard.kt @@ -1,5 +1,6 @@ package eu.m724.vastapp.activity.dashboard.screen +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -31,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -41,6 +43,7 @@ import eu.m724.vastapp.activity.dashboard.DashboardViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) // for pullRefresh @Composable fun DashboardScreen(dashboardViewModel: DashboardViewModel) { + val context = LocalContext.current val uiState by dashboardViewModel.uiState.collectAsState() val user by dashboardViewModel.user.collectAsState() @@ -53,7 +56,7 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) { PullToRefreshBox( isRefreshing = isRefreshing, state = rememberPullToRefreshState(), - onRefresh = { dashboardViewModel.refresh() } + onRefresh = { dashboardViewModel.refresh(context as ComponentActivity) } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Help.kt b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Help.kt index 2242a1c..7417fba 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Help.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Help.kt @@ -2,6 +2,7 @@ package eu.m724.vastapp.activity.dashboard.screen import android.content.Intent import android.net.Uri +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -12,11 +13,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.sp +import eu.m724.vastapp.activity.Opener import eu.m724.vastapp.activity.dashboard.DashboardViewModel @Composable fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a webview - val context = LocalContext.current + val activity = LocalContext.current as ComponentActivity Column( modifier = Modifier.fillMaxSize(), @@ -24,8 +26,7 @@ fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a web verticalArrangement = Arrangement.Center ) { Button(onClick = { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.google.com")) - context.startActivity(browserIntent) + Opener.openUrl("https://vast.ai/docs", activity) }) { Text(text = "https://vast.ai/docs") } diff --git a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Instances.kt b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Instances.kt index ca77fe5..2e53274 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Instances.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/dashboard/screen/Instances.kt @@ -1,12 +1,16 @@ package eu.m724.vastapp.activity.dashboard.screen +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ContextualFlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.layout.width @@ -14,20 +18,36 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import eu.m724.vastapp.R +import eu.m724.vastapp.activity.PermissionChecker import eu.m724.vastapp.activity.dashboard.DashboardViewModel import eu.m724.vastapp.vastai.data.RentedInstance import org.json.JSONObject @@ -38,8 +58,11 @@ class Instances { @OptIn(ExperimentalLayoutApi::class) @Composable fun InstancesScreen(dashboardViewModel: DashboardViewModel) { + val activity = LocalContext.current as ComponentActivity + val uiState by dashboardViewModel.uiState.collectAsState() val rentedInstances by dashboardViewModel.rentedInstances.collectAsState() + val termuxAvailable by dashboardViewModel.termuxAvailable.collectAsState() // TODO actually get instances @@ -50,101 +73,63 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) { itemCount = rentedInstances.size, horizontalArrangement = Arrangement.Center ) { i -> - RentedInstanceCard(rentedInstance = rentedInstances[i], modifier = Modifier - .width(340.dp) - .padding(8.dp)) + RentedInstanceCard( + modifier = Modifier + .width(340.dp) + .padding(8.dp), + rentedInstance = rentedInstances[i], + termuxAvailable = termuxAvailable, + sshButtonClick = { + dashboardViewModel.sshButtonClick(activity, it) + }, + ) } } +// TODO maybe move this? @Composable -fun RentedInstanceCard(rentedInstance: RentedInstance, modifier: Modifier = Modifier) { +fun RentedInstanceCard( + modifier: Modifier = Modifier, + rentedInstance: RentedInstance, + termuxAvailable: Int, + sshButtonClick: (RentedInstance) -> Unit, +) { val instance by remember(rentedInstance) { derivedStateOf { rentedInstance.instance } } val label by remember(instance) { derivedStateOf { rentedInstance.label ?: instance.machine.gpu.model } } Card(modifier = modifier) { - Row { - Text(label, fontSize = 18.sp) + Row(modifier = Modifier.padding(8.dp)) { + Text(label, fontSize = 22.sp) Spacer(modifier = Modifier.weight(1f)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Details about instance $label" - ) - } - } -} -// TODO maybe move this? -@Composable -fun InstanceCard(instance: JSONObject, modifier: Modifier = Modifier) { - val gpuUsage = instance.getInt("gpu_util") - val vramGb = instance.getInt("gpu_ram") / 1000.0 - val vramGbUsed = vramGb * instance.getDouble("vmem_usage") - - Card(modifier = modifier) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + Column( + horizontalAlignment = Alignment.End ) { - Text(text = instance.getString("id"), fontSize = 14.sp) - Text(text = "m:" + instance.getString("machine_id"), fontSize = 14.sp) - Text(text = "h:" + instance.getString("host_id"), fontSize = 14.sp) - } - - Row { - Column { - Row( - modifier = Modifier.fillMaxWidth(0.5f), - verticalAlignment = Alignment.Bottom - ) { - Text(text = instance.getString("gpu_name"), fontSize = 24.sp) - if (instance.getInt("num_gpus") > 1) { - Text(text = "x" + instance.getString("num_gpus"), modifier = Modifier.padding(start = 2.dp)) - } - } - - Row( - modifier = Modifier.fillMaxWidth(0.5f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Text(text = "GPU: $gpuUsage%", fontSize = 12.sp) - LinearProgressIndicator( - progress = { gpuUsage / 100f } - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Text(text = "%.1f / %.1f G".format(vramGbUsed, vramGb), fontSize = 12.sp) - LinearProgressIndicator( - progress = { instance.getDouble("vmem_usage").toFloat() } - ) - } - + Button( // TODO consider other buttons + modifier = Modifier.height(24.dp), + contentPadding = PaddingValues(0.dp), + onClick = { sshButtonClick(rentedInstance) } + ) { + if (termuxAvailable > -1) { + Icon( + painter = painterResource(id = R.drawable.termux_icon), + contentDescription = "Run in Termux" + ) + Text("ssh") + Spacer(modifier = Modifier.size(4.dp)) // necessary because TODO the termux icon has padding + } else { + Text("ssh") } } + + Spacer(modifier = Modifier.height(6.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Details about instance $label" + ) } } } -} - -@Preview -@Composable -fun PreviewInstanceCard() { - val instance = JSONObject() - instance.put("id", 3423941) - InstanceCard(instance = instance, modifier = Modifier.size(300.dp)) } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/login/LoginActivity.kt b/app/src/main/java/eu/m724/vastapp/activity/login/LoginActivity.kt index b6ad807..0af0821 100644 --- a/app/src/main/java/eu/m724/vastapp/activity/login/LoginActivity.kt +++ b/app/src/main/java/eu/m724/vastapp/activity/login/LoginActivity.kt @@ -78,27 +78,6 @@ class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // TODO move this where and run this when we need it - val permissionChecker = PermissionChecker(applicationContext) - if (!permissionChecker.permissionExists("com.termux.permission.RUN_COMMAND")) { - Toast.makeText( - applicationContext, - R.string.no_termux, - Toast.LENGTH_SHORT - ).show() - } else { - permissionChecker.requestIfNoPermission("com.termux.permission.RUN_COMMAND", this) { granted, asked -> - if (granted || !asked) return@requestIfNoPermission - - Toast.makeText( - applicationContext, - getString(R.string.command_permission_denied), - Toast.LENGTH_SHORT - ).show() - } - - } - dashboardLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { _ -> finish() } // TODO re-login here diff --git a/app/src/main/java/eu/m724/vastapp/activity/termux/TermuxSshActivity.kt b/app/src/main/java/eu/m724/vastapp/activity/termux/TermuxSshActivity.kt new file mode 100644 index 0000000..27d027e --- /dev/null +++ b/app/src/main/java/eu/m724/vastapp/activity/termux/TermuxSshActivity.kt @@ -0,0 +1,160 @@ +package eu.m724.vastapp.activity.termux + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.m724.vastapp.R +import eu.m724.vastapp.activity.Opener +import eu.m724.vastapp.activity.termux.ui.theme.VastappTheme + +class TermuxSshActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val termuxResult = intent.getBundleExtra("result") + val exitCode = termuxResult!!.getInt("exitCode") + val internalErrorCode = termuxResult.getInt("err") + val stdout = termuxResult.getString("stdout", "") + println(exitCode) + println("C" + termuxResult.getString("stdout")) + println(termuxResult.getInt("err")) + + var msg = stdout + + if (internalErrorCode == -1) { + if (exitCode == 0) { + if (stdout.isEmpty()) { + Toast.makeText( + applicationContext, + getString(R.string.termux_no_ssh), + Toast.LENGTH_SHORT + ).show() + } + + finish() + } + } else { + msg = termuxResult.getString("errmsg") + } + + enableEdgeToEdge() + setContent { + VastappTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (internalErrorCode == 150) + SshNotInstalled( + onCopyButtonClick = { + Opener.copyToClipboard(it, "Termux command", this@TermuxSshActivity) + }, + onOpenTermuxButton = { + Opener.openApp("com.termux", this@TermuxSshActivity) + } + ) + else if (internalErrorCode == 2) + TermuxSetupGuide( + onUrlButtonClick = { + Opener.openUrl(it, this@TermuxSshActivity) + } + ) + else + UnexpectedError(msg) + } + } + } + } + } +} + +@Composable +fun UnexpectedError(msg: String) { + Text("An error occured:") + Card( + modifier = Modifier.padding(16.dp) + ) { + Text( + modifier = Modifier.padding(12.dp, 10.dp), + text = msg + ) + } +} + +@Composable +fun SshNotInstalled(onCopyButtonClick: (String) -> Unit, onOpenTermuxButton: () -> Unit) { + Text("No SSH client installed.") + Text("Install Dropbear with:") + + Spacer(modifier = Modifier.height(16.dp)) + Card { + Row( + modifier = Modifier.padding(12.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(24.dp + 8.dp)) + Text("pkg install dropbear") + + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + modifier = Modifier.size(24.dp), + onClick = { onCopyButtonClick("pkg install dropbear") } + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Outlined.Home, // TODO copy icon here + contentDescription = "Copy command" + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton(onClick = onOpenTermuxButton) { + Text("Open Termux") + } +} + +@Composable +fun TermuxSetupGuide(onUrlButtonClick: (String) -> Unit) { + Text("Termux is not configured for usage with other apps.") + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton(onClick = { onUrlButtonClick("https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent#Setup-Instructions") }) { + Text("Open instructions on github.com") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Color.kt b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Color.kt new file mode 100644 index 0000000..881a269 --- /dev/null +++ b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package eu.m724.vastapp.activity.termux.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Theme.kt b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Theme.kt new file mode 100644 index 0000000..6aebeb8 --- /dev/null +++ b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package eu.m724.vastapp.activity.termux.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun VastappTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Type.kt b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Type.kt new file mode 100644 index 0000000..14b5a46 --- /dev/null +++ b/app/src/main/java/eu/m724/vastapp/activity/termux/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package eu.m724.vastapp.activity.termux.ui.theme + +import androidx.compose.material3.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( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/vastapp/vastai/data/RentedInstance.kt b/app/src/main/java/eu/m724/vastapp/vastai/data/RentedInstance.kt index 8a41c81..1ae80f8 100644 --- a/app/src/main/java/eu/m724/vastapp/vastai/data/RentedInstance.kt +++ b/app/src/main/java/eu/m724/vastapp/vastai/data/RentedInstance.kt @@ -57,7 +57,7 @@ data class RentedInstance( json.optDouble("gpu_temp"), json.optDouble("inet_down_billed").toInt(), json.optDouble("inet_up_billed").toInt(), - json.getString("ssh_host"), + "ssh${json.getInt("ssh_idx")}.vast.ai", // TODO json.getInt("ssh_port"), json.getString("image_uuid"), json.optString("label").takeUnless { it == "null" || it.isBlank() }, diff --git a/app/src/main/res/drawable/termux_icon.xml b/app/src/main/res/drawable/termux_icon.xml new file mode 100644 index 0000000..e8906ed --- /dev/null +++ b/app/src/main/res/drawable/termux_icon.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87bb00f..9b48c7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,4 +22,7 @@ none yet sorry If you change your mind, do so from settings Termuxn\'t + TermuxSshActivity + No ssh client on termux, install dropbear or openssh package + Copied command to clipboard \ No newline at end of file