Compare commits

..

No commits in common. "9d0401c0f6e70f250e81200e40571e1ce1453d0d" and "3ae52c6638b61a29a768b2fcbf7d840e5a49f3f2" have entirely different histories.

20 changed files with 192 additions and 581 deletions

View file

@ -8,19 +8,14 @@
<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

@ -1,28 +0,0 @@
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,75 +7,62 @@ 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 { class PermissionChecker(private val context: Context) {
companion object { /**
* 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
}
/** /**
* check if the app has a permission * check if a permission exists or if the app is installed
* @param permission the permission * @param permission the permission
* @param context application context * @return if the permission exists
* @return whether the app has the permission? obviously */
*/ fun permissionExists(permission: String): Boolean {
fun hasPermission(permission: String, context: Context): Boolean { try {
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED context.packageManager.getPermissionInfo(permission, 0)
} return true
} catch (e: NameNotFoundException) {
/** return 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
}
}
/**
* @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)
}
} }
} }
/**
* @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)
}
}
// 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)
}
} }

View file

@ -49,13 +49,12 @@ 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).enableBrotli(true).build() val cronetEngine = CronetEngine.Builder(baseContext).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(this@DashboardActivity)
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
dashboardViewModel.refreshError.collect { dashboardViewModel.refreshError.collect {
it.forEach { errorMsg -> it.forEach { errorMsg ->

View file

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

View file

@ -1,22 +1,11 @@
package eu.m724.vastapp.activity.dashboard 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 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.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
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import eu.m724.vastapp.vastai.data.RentedInstance import eu.m724.vastapp.vastai.data.Instance
import eu.m724.vastapp.vastai.data.User import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -24,18 +13,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class DashboardViewModel( class DashboardViewModel(initialUser: User, private val vastApi: VastApi) : ViewModel() { // TODO do something with the user
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> =
_uiState.asStateFlow() _uiState.asStateFlow()
private val _rentedInstances: MutableStateFlow<List<RentedInstance>> = MutableStateFlow(emptyList()) private val _rentedInstances: MutableStateFlow<List<Instance>> = MutableStateFlow(emptyList())
val rentedInstances: StateFlow<List<RentedInstance>> = _rentedInstances.asStateFlow() val rentedInstances: StateFlow<List<Instance>> = _rentedInstances.asStateFlow()
private val _user: MutableStateFlow<User> = MutableStateFlow(initialUser) private val _user: MutableStateFlow<User> = MutableStateFlow(initialUser)
val user: StateFlow<User> = _user.asStateFlow() val user: StateFlow<User> = _user.asStateFlow()
@ -43,10 +28,7 @@ class DashboardViewModel(
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()
private val _termuxAvailable: MutableStateFlow<Int> = MutableStateFlow(0) fun refresh() {
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()
@ -83,77 +65,7 @@ class DashboardViewModel(
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
} }
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,6 +1,5 @@
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
@ -26,13 +25,13 @@ 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.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable 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
@ -43,7 +42,6 @@ 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()
@ -56,7 +54,7 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = rememberPullToRefreshState(), state = rememberPullToRefreshState(),
onRefresh = { dashboardViewModel.refresh(context as ComponentActivity) } onRefresh = { dashboardViewModel.refresh() }
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -159,12 +157,12 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
@Composable @Composable
fun balanceCardColor(balance: Double): Color { fun balanceCardColor(balance: Double): Color {
return if (balance > 0) Color.Unspecified else MaterialTheme.colorScheme.errorContainer return if (balance > 0) CardDefaults.cardColors().containerColor else MaterialTheme.colorScheme.errorContainer
} }
@Composable @Composable
fun balanceColor(balance: Double, warningThreshold: Double): Color { fun balanceColor(balance: Double, warningThreshold: Double): Color {
return if (balance > warningThreshold) Color.Unspecified else MaterialTheme.colorScheme.error return if (balance > warningThreshold) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error
} }
@Composable @Composable

View file

@ -2,7 +2,6 @@ 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,15 +11,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.res.stringResource
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.m724.vastapp.R
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 activity = LocalContext.current as ComponentActivity val context = LocalContext.current
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -28,10 +24,11 @@ fun HelpScreen(dashboardViewModel: DashboardViewModel) { // TODO make this a web
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Button(onClick = { Button(onClick = {
Opener.openUrl("https://vast.ai/docs", activity) val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.google.com"))
context.startActivity(browserIntent)
}) { }) {
Text(text = "https://vast.ai/docs") Text(text = "https://vast.ai/docs")
} }
Text(text = stringResource(id = R.string.webview_todo), fontSize = 12.sp) Text(text = "(this will be a webview)", fontSize = 12.sp)
} }
} }

View file

@ -1,10 +1,11 @@
package eu.m724.vastapp.activity.dashboard.screen package eu.m724.vastapp.activity.dashboard.screen
import androidx.activity.ComponentActivity import android.widget.ProgressBar
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.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
@ -13,37 +14,30 @@ 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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState 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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.tooling.preview.Preview
import androidx.compose.ui.res.painterResource
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.dashboard.DashboardViewModel import eu.m724.vastapp.activity.dashboard.DashboardViewModel
import eu.m724.vastapp.vastai.data.RentedInstance import org.json.JSONObject
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 termuxAvailable by dashboardViewModel.termuxAvailable.collectAsState()
// TODO actually get instances // TODO actually get instances
@ -51,73 +45,93 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
itemCount = rentedInstances.size, itemCount = 10,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { i -> ) {
RentedInstanceCard( val instance = JSONObject()
modifier = Modifier instance.put("id", 234523)
.width(340.dp) instance.put("machine_id", 1121323)
.padding(8.dp), instance.put("host_id", 5924)
rentedInstance = rentedInstances[i], instance.put("gpu_name", "RTX 4090")
termuxAvailable = termuxAvailable, instance.put("num_gpus", 2)
sshButtonClick = { instance.put("gpu_util", 70)
dashboardViewModel.sshButtonClick(activity, it) instance.put("gpu_ram", 24564)
}, instance.put("vmem_usage", 0.339843)
)
InstanceCard(instance = instance, modifier = Modifier.width(340.dp).padding(8.dp))
} }
} }
// TODO maybe move this? // TODO maybe move this?
@Composable @Composable
fun RentedInstanceCard( fun InstanceCard(instance: JSONObject, modifier: Modifier = Modifier) {
modifier: Modifier = Modifier, val gpuUsage = instance.getInt("gpu_util")
rentedInstance: RentedInstance, val vramGb = instance.getInt("gpu_ram") / 1000.0
termuxAvailable: Int, val vramGbUsed = vramGb * instance.getDouble("vmem_usage")
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) { Card(modifier = modifier) {
Row(modifier = Modifier.padding(8.dp)) { Column(
Text(label, fontSize = 22.sp) modifier = Modifier
Spacer(modifier = Modifier.weight(1f)) .fillMaxWidth()
.padding(horizontal = 16.dp)
Column( ) {
horizontalAlignment = Alignment.End Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Button( // TODO consider other buttons Text(text = instance.getString("id"), fontSize = 14.sp)
modifier = Modifier.height(24.dp), Text(text = "m:" + instance.getString("machine_id"), fontSize = 14.sp)
contentPadding = PaddingValues(0.dp), Text(text = "h:" + instance.getString("host_id"), fontSize = 14.sp)
onClick = { sshButtonClick(rentedInstance) } }
) {
if (termuxAvailable > -1) { Row {
Icon( Column {
painter = painterResource(id = R.drawable.termux_icon), Row(
contentDescription = "Run in Termux" modifier = Modifier.fillMaxWidth(0.5f),
) verticalAlignment = Alignment.Bottom
Text("ssh") ) {
Spacer(modifier = Modifier.size(4.dp)) // necessary because TODO the termux icon has padding Text(text = instance.getString("gpu_name"), fontSize = 24.sp)
} else { if (instance.getInt("num_gpus") > 1) {
Spacer(modifier = Modifier.size(1.dp)) // TODO make this not needed? Text(text = "x" + instance.getString("num_gpus"), modifier = Modifier.padding(start = 2.dp))
Icon( }
modifier = Modifier.size(12.dp), }
painter = painterResource(id = R.drawable.copy_regular), // TODO copy icon here
contentDescription = "Copy command" Row(
) modifier = Modifier.fillMaxWidth(0.5f)
Spacer(modifier = Modifier.size(6.dp)) ) {
Text("ssh") 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() }
)
}
} }
} }
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,6 +78,27 @@ 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

@ -1,142 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,58 +0,0 @@
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

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
<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

@ -1,28 +0,0 @@
<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,13 +22,4 @@
<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>
<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> </resources>