implement ssh button and misc improvements

a lot for one commit but I didn't wanna commit every single line of code
This commit is contained in:
Minecon724 2024-08-01 18:51:26 +02:00
parent 74aefb6592
commit 3829e9ce3f
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
16 changed files with 562 additions and 162 deletions

View file

@ -8,14 +8,19 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Vastapp" android:theme="@style/Theme.Vastapp"
android:enableOnBackInvokedCallback="true"
tools:targetApi="34"> tools:targetApi="34">
<activity
android:name=".activity.termux.TermuxSshActivity"
android:exported="false"
android:label="@string/title_activity_termux_ssh"
android:theme="@style/Theme.Vastapp" />
<activity <activity
android:name=".activity.dashboard.DashboardActivity" android:name=".activity.dashboard.DashboardActivity"
android:exported="false" android:exported="false"

View file

@ -0,0 +1,28 @@
package eu.m724.vastapp.activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
class Opener {
companion object {
fun openUrl(url: String, activity: ComponentActivity) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
activity.startActivity(intent)
}
fun openApp(packageName: String, activity: ComponentActivity) {
val intent = activity.packageManager.getLaunchIntentForPackage(packageName)
activity.startActivity(intent)
}
fun copyToClipboard(text: String, label: String, context: Context) {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText(label, text)
clipboardManager.setPrimaryClip(clipData)
}
}
}

View file

@ -7,24 +7,28 @@ import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
class PermissionChecker(private val context: Context) { class PermissionChecker {
companion object {
/** /**
* check if the app has a permission * check if the app has a permission
* @param permission the permission * @param permission the permission
* @param context application context
* @return whether the app has the permission? obviously * @return whether the app has the permission? obviously
*/ */
fun hasPermission(permission: String): Boolean { fun hasPermission(permission: String, context: Context): Boolean {
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
} }
/** /**
* check if a permission exists or if the app is installed * check if a permission exists or if the app is installed
* @param permission the permission * @param permission the permission
* @param packageManager usually from application context
* @return if the permission exists * @return if the permission exists
*/ */
fun permissionExists(permission: String): Boolean { fun permissionExists(permission: String, packageManager: PackageManager): Boolean {
try { try {
context.packageManager.getPermissionInfo(permission, 0) packageManager.getPermissionInfo(permission, 0)
return true return true
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
return false return false
@ -44,25 +48,34 @@ class PermissionChecker(private val context: Context) {
* request a permission if that permission is not granted * request a permission if that permission is not granted
* @param permission the permission * @param permission the permission
* @param activity the activity you're calling from * @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 * @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, callback: (Boolean, Boolean) -> Unit) { fun requestIfNoPermission(permission: String, activity: ComponentActivity, requestCode: Int? = null, callback: (Boolean, Boolean) -> Unit) {
val available = canAskForPermission(permission, activity) val available = canAskForPermission(permission, activity)
if (hasPermission(permission)) { if (hasPermission(permission, activity.applicationContext)) {
callback(true, false) callback(true, false)
} else if (available) { // no permission but can request } else if (available) { // no permission but can request
requestPermission(permission, activity) { callback(it, true) } requestPermission(permission, activity, requestCode) { callback(it, true) }
} else { // no permission and can't request } else { // no permission and can't request
callback(false, false) 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 // 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) { private fun requestPermission(permission: String, activity: ComponentActivity, requestCode: Int? = null, callback: (Boolean) -> Unit) {
if (requestCode != null) {
activity.requestPermissions(
arrayOf(permission),
requestCode
)
} else {
activity.registerForActivityResult( activity.registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
callback callback
).launch(permission) ).launch(permission)
} }
}
}
} }

View file

@ -49,13 +49,13 @@ class DashboardActivity : ComponentActivity() {
val user = intent.getParcelableExtra<User>("user")!! // TODO null check val user = intent.getParcelableExtra<User>("user")!! // TODO null check
val executor = Executors.newSingleThreadExecutor() 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 vastApi = VastApi(user.apiKey, cronetEngine, executor) // TODO use that from login activity
val dashboardViewModel = DashboardViewModel(user, vastApi) val dashboardViewModel = DashboardViewModel(user, vastApi)
lifecycleScope.launch { lifecycleScope.launch {
dashboardViewModel.refresh() dashboardViewModel.refresh(this@DashboardActivity)
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
dashboardViewModel.refreshError.collect { dashboardViewModel.refreshError.collect {
it.forEach { errorMsg -> it.forEach { errorMsg ->

View file

@ -1,6 +1,21 @@
package eu.m724.vastapp.activity.dashboard 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
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.ApiRoute
import eu.m724.vastapp.vastai.VastApi import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.api.InstancesUrlRequestCallback import eu.m724.vastapp.vastai.api.InstancesUrlRequestCallback
@ -13,7 +28,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update 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<DashboardUiState> = private val _uiState: MutableStateFlow<DashboardUiState> =
MutableStateFlow(DashboardUiState(0)) MutableStateFlow(DashboardUiState(0))
val uiState: StateFlow<DashboardUiState> = val uiState: StateFlow<DashboardUiState> =
@ -28,7 +47,10 @@ class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : View
private val _refreshError: MutableStateFlow<List<String>> = MutableStateFlow(emptyList()) private val _refreshError: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
val refreshError: StateFlow<List<String>> = _refreshError.asStateFlow() val refreshError: StateFlow<List<String>> = _refreshError.asStateFlow()
fun refresh() { private val _termuxAvailable: MutableStateFlow<Int> = MutableStateFlow(0)
val termuxAvailable: StateFlow<Int> = _termuxAvailable.asStateFlow()
fun refresh(activity: ComponentActivity) {
_uiState.value = _uiState.value.copy(refreshing = 2) _uiState.value = _uiState.value.copy(refreshing = 2)
_refreshError.value = emptyList() _refreshError.value = emptyList()
@ -65,7 +87,77 @@ class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : View
userRequest.start() userRequest.start()
instancesRequest.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 // 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<String>) {
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)
}
} }

View file

@ -1,5 +1,6 @@
package eu.m724.vastapp.activity.dashboard.screen package eu.m724.vastapp.activity.dashboard.screen
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -41,6 +43,7 @@ import eu.m724.vastapp.activity.dashboard.DashboardViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) // for pullRefresh @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) // for pullRefresh
@Composable @Composable
fun DashboardScreen(dashboardViewModel: DashboardViewModel) { fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
val context = LocalContext.current
val uiState by dashboardViewModel.uiState.collectAsState() val uiState by dashboardViewModel.uiState.collectAsState()
val user by dashboardViewModel.user.collectAsState() val user by dashboardViewModel.user.collectAsState()
@ -53,7 +56,7 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = rememberPullToRefreshState(), state = rememberPullToRefreshState(),
onRefresh = { dashboardViewModel.refresh() } onRefresh = { dashboardViewModel.refresh(context as ComponentActivity) }
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View file

@ -2,6 +2,7 @@ package eu.m724.vastapp.activity.dashboard.screen
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -12,11 +13,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.m724.vastapp.activity.Opener
import eu.m724.vastapp.activity.dashboard.DashboardViewModel import eu.m724.vastapp.activity.dashboard.DashboardViewModel
@Composable @Composable
fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a webview fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a webview
val context = LocalContext.current val activity = LocalContext.current as ComponentActivity
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -24,8 +26,7 @@ fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a web
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Button(onClick = { Button(onClick = {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.google.com")) Opener.openUrl("https://vast.ai/docs", activity)
context.startActivity(browserIntent)
}) { }) {
Text(text = "https://vast.ai/docs") Text(text = "https://vast.ai/docs")
} }

View file

@ -1,12 +1,16 @@
package eu.m724.vastapp.activity.dashboard.screen package eu.m724.vastapp.activity.dashboard.screen
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ContextualFlowRow import androidx.compose.foundation.layout.ContextualFlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -14,20 +18,36 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.activity.dashboard.DashboardViewModel
import eu.m724.vastapp.vastai.data.RentedInstance import eu.m724.vastapp.vastai.data.RentedInstance
import org.json.JSONObject import org.json.JSONObject
@ -38,8 +58,11 @@ class Instances {
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun InstancesScreen(dashboardViewModel: DashboardViewModel) { fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
val activity = LocalContext.current as ComponentActivity
val uiState by dashboardViewModel.uiState.collectAsState() val uiState by dashboardViewModel.uiState.collectAsState()
val rentedInstances by dashboardViewModel.rentedInstances.collectAsState() val rentedInstances by dashboardViewModel.rentedInstances.collectAsState()
val termuxAvailable by dashboardViewModel.termuxAvailable.collectAsState()
// TODO actually get instances // TODO actually get instances
@ -50,101 +73,63 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
itemCount = rentedInstances.size, itemCount = rentedInstances.size,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { i -> ) { i ->
RentedInstanceCard(rentedInstance = rentedInstances[i], modifier = Modifier RentedInstanceCard(
modifier = Modifier
.width(340.dp) .width(340.dp)
.padding(8.dp)) .padding(8.dp),
rentedInstance = rentedInstances[i],
termuxAvailable = termuxAvailable,
sshButtonClick = {
dashboardViewModel.sshButtonClick(activity, it)
},
)
} }
} }
// TODO maybe move this?
@Composable @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 instance by remember(rentedInstance) { derivedStateOf { rentedInstance.instance } }
val label by remember(instance) { derivedStateOf { val label by remember(instance) { derivedStateOf {
rentedInstance.label ?: instance.machine.gpu.model rentedInstance.label ?: instance.machine.gpu.model
} } } }
Card(modifier = modifier) { Card(modifier = modifier) {
Row { Row(modifier = Modifier.padding(8.dp)) {
Text(label, fontSize = 18.sp) Text(label, fontSize = 22.sp)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Column(
horizontalAlignment = Alignment.End
) {
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( Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Details about instance $label" 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
) {
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() }
)
}
}
}
}
}
} }
} }
@Preview
@Composable
fun PreviewInstanceCard() {
val instance = JSONObject()
instance.put("id", 3423941)
InstanceCard(instance = instance, modifier = Modifier.size(300.dp))
}

View file

@ -78,27 +78,6 @@ class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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( dashboardLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { _ -> finish() } // TODO re-login here ) { _ -> finish() } // TODO re-login here

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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
)
}

View file

@ -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
)
*/
)

View file

@ -57,7 +57,7 @@ data class RentedInstance(
json.optDouble("gpu_temp"), json.optDouble("gpu_temp"),
json.optDouble("inet_down_billed").toInt(), json.optDouble("inet_down_billed").toInt(),
json.optDouble("inet_up_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.getInt("ssh_port"),
json.getString("image_uuid"), json.getString("image_uuid"),
json.optString("label").takeUnless { it == "null" || it.isBlank() }, json.optString("label").takeUnless { it == "null" || it.isBlank() },

View file

@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Keep in sync with non-adaptive ic_launcher.xml -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38
h6
l12,16
l-12,16
h-6
l12,-16
"
/>
<path
android:fillColor="#FFFFFF"
android:pathData="M56,66
h18
v4
h-18
"
/>
</vector>

View file

@ -22,4 +22,7 @@
<string name="no_options">none yet sorry</string> <string name="no_options">none yet sorry</string>
<string name="command_permission_denied">If you change your mind, do so from settings</string> <string name="command_permission_denied">If you change your mind, do so from settings</string>
<string name="no_termux">Termuxn\'t</string> <string name="no_termux">Termuxn\'t</string>
<string name="title_activity_termux_ssh">TermuxSshActivity</string>
<string name="termux_no_ssh">No ssh client on termux, install dropbear or openssh package</string>
<string name="copied_to_clipboard">Copied command to clipboard</string>
</resources> </resources>