Compare commits

..

11 commits

Author SHA1 Message Date
9d0401c0f6
add copy icon 2024-08-02 17:05:48 +02:00
2ed3d5da54
move a line 2024-08-02 14:46:18 +02:00
b8c7a62e58
remove unused imports
how to automate this
2024-08-01 19:01:52 +02:00
f33bc55f68
make more things translatable 2024-08-01 18:58:22 +02:00
3829e9ce3f
implement ssh button and misc improvements
a lot for one commit but I didn't wanna commit every single line of code
2024-08-01 18:51:26 +02:00
74aefb6592
remove unused imports 2024-08-01 14:29:06 +02:00
38ce999965
fix balance card color 2024-08-01 14:28:24 +02:00
1d24005e5e
refresh on start 2024-08-01 14:26:21 +02:00
bdd6fefed1
new instances view 2024-08-01 14:26:16 +02:00
a48c19833b
fix rentedinstance object 2024-08-01 14:25:59 +02:00
0a85bb20e0
rentedinstance on instances callback and dashboard view model 2024-08-01 14:07:14 +02:00
20 changed files with 578 additions and 189 deletions

View file

@ -8,14 +8,19 @@
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Vastapp"
android:enableOnBackInvokedCallback="true"
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
android:name=".activity.dashboard.DashboardActivity"
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,62 +7,75 @@ import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
class PermissionChecker(private val context: Context) {
/**
* check if the app has a permission
* @param permission the permission
* @return whether the app has the permission? obviously
*/
fun hasPermission(permission: String): Boolean {
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
class PermissionChecker {
companion object {
/**
* check if a permission exists or if the app is installed
* @param permission the permission
* @return if the permission exists
*/
fun permissionExists(permission: String): Boolean {
try {
context.packageManager.getPermissionInfo(permission, 0)
return true
} catch (e: NameNotFoundException) {
return false
/**
* check if the app has a permission
* @param permission the permission
* @param context application context
* @return whether the app has the permission? obviously
*/
fun hasPermission(permission: String, context: Context): Boolean {
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
/**
* @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 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) {
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)
}
}
}
}

View file

@ -49,12 +49,13 @@ class DashboardActivity : ComponentActivity() {
val user = intent.getParcelableExtra<User>("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(this@DashboardActivity)
repeatOnLifecycle(Lifecycle.State.STARTED) {
dashboardViewModel.refreshError.collect {
it.forEach { errorMsg ->

View file

@ -2,5 +2,4 @@ package eu.m724.vastapp.activity.dashboard
data class DashboardUiState(
val refreshing: Int = 0
) {
}
)

View file

@ -1,11 +1,22 @@
package eu.m724.vastapp.activity.dashboard
import android.annotation.SuppressLint
import android.app.PendingIntent
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 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
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import eu.m724.vastapp.vastai.data.Instance
import eu.m724.vastapp.vastai.data.RentedInstance
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -13,14 +24,18 @@ 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<DashboardUiState> =
MutableStateFlow(DashboardUiState(0))
val uiState: StateFlow<DashboardUiState> =
_uiState.asStateFlow()
private val _rentedInstances: MutableStateFlow<List<Instance>> = MutableStateFlow(emptyList())
val rentedInstances: StateFlow<List<Instance>> = _rentedInstances.asStateFlow()
private val _rentedInstances: MutableStateFlow<List<RentedInstance>> = MutableStateFlow(emptyList())
val rentedInstances: StateFlow<List<RentedInstance>> = _rentedInstances.asStateFlow()
private val _user: MutableStateFlow<User> = MutableStateFlow(initialUser)
val user: StateFlow<User> = _user.asStateFlow()
@ -28,7 +43,10 @@ class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : View
private val _refreshError: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
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)
_refreshError.value = emptyList()
@ -65,7 +83,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
}
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)
}
@SuppressLint("SdCardPath")
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
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -25,13 +26,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
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.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
@ -42,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()
@ -54,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,
@ -157,12 +159,12 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
@Composable
fun balanceCardColor(balance: Double): Color {
return if (balance > 0) CardDefaults.cardColors().containerColor else MaterialTheme.colorScheme.errorContainer
return if (balance > 0) Color.Unspecified else MaterialTheme.colorScheme.errorContainer
}
@Composable
fun balanceColor(balance: Double, warningThreshold: Double): Color {
return if (balance > warningThreshold) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error
return if (balance > warningThreshold) Color.Unspecified else MaterialTheme.colorScheme.error
}
@Composable

View file

@ -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
@ -11,12 +12,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import eu.m724.vastapp.R
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,11 +28,10 @@ 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")
}
Text(text = "(this will be a webview)", fontSize = 12.sp)
Text(text = stringResource(id = R.string.webview_todo), fontSize = 12.sp)
}
}

View file

@ -1,11 +1,10 @@
package eu.m724.vastapp.activity.dashboard.screen
import android.widget.ProgressBar
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
@ -14,30 +13,37 @@ 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.lazy.LazyColumn
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.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Icon
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.m724.vastapp.R
import eu.m724.vastapp.activity.dashboard.DashboardViewModel
import org.json.JSONObject
class Instances {
}
import eu.m724.vastapp.vastai.data.RentedInstance
@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
@ -45,93 +51,73 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
itemCount = 10,
itemCount = rentedInstances.size,
horizontalArrangement = Arrangement.Center
) {
val instance = JSONObject()
instance.put("id", 234523)
instance.put("machine_id", 1121323)
instance.put("host_id", 5924)
instance.put("gpu_name", "RTX 4090")
instance.put("num_gpus", 2)
instance.put("gpu_util", 70)
instance.put("gpu_ram", 24564)
instance.put("vmem_usage", 0.339843)
InstanceCard(instance = instance, modifier = Modifier.width(340.dp).padding(8.dp))
) { i ->
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 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")
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) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
Row(modifier = Modifier.padding(8.dp)) {
Text(label, fontSize = 22.sp)
Spacer(modifier = Modifier.weight(1f))
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 {
Spacer(modifier = Modifier.size(1.dp)) // TODO make this not needed?
Icon(
modifier = Modifier.size(12.dp),
painter = painterResource(id = R.drawable.copy_regular), // TODO copy icon here
contentDescription = "Copy command"
)
Spacer(modifier = Modifier.size(6.dp))
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))
}

View file

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

View file

@ -0,0 +1,142 @@
package eu.m724.vastapp.activity.termux
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.Card
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.res.stringResource
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", "")
var msg = stdout
if (internalErrorCode == -1) {
if (exitCode == 0) {
finish()
} // TODO handle other errors like 255 is connection refused
} 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(stringResource(id = R.string.termux_error))
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(stringResource(id = R.string.termux_no_ssh))
Text(stringResource(id = R.string.termux_install_dropbear))
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(20.dp),
painter = painterResource(id = R.drawable.copy_regular),
contentDescription = "Copy command",
tint = LocalTextStyle.current.color
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(onClick = onOpenTermuxButton) {
Text(stringResource(id = R.string.open_termux))
}
}
@Composable
fun TermuxSetupGuide(onUrlButtonClick: (String) -> Unit) {
Text(stringResource(id = R.string.termux_not_configured))
Spacer(modifier = Modifier.height(16.dp))
FilledTonalButton(onClick = { onUrlButtonClick("https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent#Setup-Instructions") }) {
Text(stringResource(id = R.string.termux_open_instructions))
}
}

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

@ -1,7 +1,7 @@
package eu.m724.vastapp.vastai.api
import eu.m724.vastapp.vastai.ApiFailure
import eu.m724.vastapp.vastai.data.Instance
import eu.m724.vastapp.vastai.data.RentedInstance
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
@ -10,7 +10,7 @@ import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
class InstancesUrlRequestCallback(
val onSuccess: (List<Instance>) -> Unit,
val onSuccess: (List<RentedInstance>) -> Unit,
val onFailure: (ApiFailure) -> Unit
) : UrlRequest.Callback() {
@ -43,11 +43,11 @@ class InstancesUrlRequestCallback(
println(stringResponse) // TODO don't do that
if (info?.httpStatusCode == 200) {
val jsonResponse = JSONObject(stringResponse.toString())
val instances = ArrayList<Instance>()
val instances = ArrayList<RentedInstance>()
val instancesJson = jsonResponse.getJSONArray("instances")
for (i in 0..<instancesJson.length()) {
instances.add(Instance.fromJson(instancesJson.getJSONObject(i)))
instances.add(RentedInstance.fromJson(instancesJson.getJSONObject(i)))
}
onSuccess(instances) // TODO handle json errors

View file

@ -50,17 +50,17 @@ data class RentedInstance(
Instance.fromJson(json),
json.getDouble("disk_space"),
json.getDouble("disk_util"),
(StorageCapacityConverters.gibToGb(json.getDouble("vmem_usage")) / 1000).toInt(),
(json.getDouble("mem_usage") / 1000).toInt(),
json.getDouble("gpu_util") / 100,
(StorageCapacityConverters.gibToGb(json.optDouble("vmem_usage")) / 1000).toInt(),
(json.optDouble("mem_usage") / 1000).toInt(),
json.optDouble("gpu_util") / 100,
json.getDouble("cpu_util") / 100,
json.getDouble("gpu_temp"),
json.getDouble("inet_down_billed").toInt(),
json.getDouble("inet_up_billed").toInt(),
json.getString("ssh_host"),
json.optDouble("gpu_temp"),
json.optDouble("inet_down_billed").toInt(),
json.optDouble("inet_up_billed").toInt(),
"ssh${json.getInt("ssh_idx")}.vast.ai", // TODO
json.getInt("ssh_port"),
json.getString("image_uuid"),
json.optString("label").takeIf { it.isNotBlank() },
json.optString("label").takeUnless { it == "null" || it.isBlank() },
json.getString("local_ipaddrs").split(" ").filterNot { it == "\n" }
)
}

View file

@ -2,7 +2,6 @@ package eu.m724.vastapp.vastai.data
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.ui.res.painterResource
data class User(
val id: String,
@ -23,8 +22,7 @@ data class User(
parcel.readDouble(),
parcel.readDouble(),
parcel.readBoolean()
) {
}
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(id)

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="200dp" android:viewportHeight="512" android:viewportWidth="448" android:width="175dp">
<path android:fillColor="#FF000000" android:pathData="M384,336l-192,0c-8.8,0 -16,-7.2 -16,-16l0,-256c0,-8.8 7.2,-16 16,-16l140.1,0L400,115.9 400,320c0,8.8 -7.2,16 -16,16zM192,384l192,0c35.3,0 64,-28.7 64,-64l0,-204.1c0,-12.7 -5.1,-24.9 -14.1,-33.9L366.1,14.1c-9,-9 -21.2,-14.1 -33.9,-14.1L192,0c-35.3,0 -64,28.7 -64,64l0,256c0,35.3 28.7,64 64,64zM64,128c-35.3,0 -64,28.7 -64,64L0,448c0,35.3 28.7,64 64,64l192,0c35.3,0 64,-28.7 64,-64l0,-32 -48,0 0,32c0,8.8 -7.2,16 -16,16L64,464c-8.8,0 -16,-7.2 -16,-16l0,-256c0,-8.8 7.2,-16 16,-16l32,0 0,-48 -32,0z"/>
</vector>

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,13 @@
<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="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>
<string name="termux_install_dropbear">Install Dropbear with:</string>
<string name="open_termux">Open Termux</string>
<string name="termux_not_configured">Termux is not configured for usage with other apps.</string>
<string name="termux_open_instructions">Open instructions on github.com</string>
<string name="termux_error">An error occured:</string>
<string name="webview_todo">(this will be a webview)</string>
</resources>