Compare commits
11 commits
3ae52c6638
...
9d0401c0f6
Author | SHA1 | Date | |
---|---|---|---|
9d0401c0f6 | |||
2ed3d5da54 | |||
b8c7a62e58 | |||
f33bc55f68 | |||
3829e9ce3f | |||
74aefb6592 | |||
38ce999965 | |||
1d24005e5e | |||
bdd6fefed1 | |||
a48c19833b | |||
0a85bb20e0 |
20 changed files with 578 additions and 189 deletions
|
@ -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"
|
||||
|
|
28
app/src/main/java/eu/m724/vastapp/activity/Opener.kt
Normal file
28
app/src/main/java/eu/m724/vastapp/activity/Opener.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,24 +7,28 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
class PermissionChecker(private val context: Context) {
|
||||
class PermissionChecker {
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* 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): Boolean {
|
||||
fun hasPermission(permission: String, context: Context): Boolean {
|
||||
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Boolean {
|
||||
fun permissionExists(permission: String, packageManager: PackageManager): Boolean {
|
||||
try {
|
||||
context.packageManager.getPermissionInfo(permission, 0)
|
||||
packageManager.getPermissionInfo(permission, 0)
|
||||
return true
|
||||
} catch (e: NameNotFoundException) {
|
||||
return false
|
||||
|
@ -44,25 +48,34 @@ class PermissionChecker(private val context: Context) {
|
|||
* 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, callback: (Boolean, Boolean) -> Unit) {
|
||||
fun requestIfNoPermission(permission: String, activity: ComponentActivity, requestCode: Int? = null, callback: (Boolean, Boolean) -> Unit) {
|
||||
val available = canAskForPermission(permission, activity)
|
||||
|
||||
if (hasPermission(permission)) {
|
||||
if (hasPermission(permission, activity.applicationContext)) {
|
||||
callback(true, false)
|
||||
} 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
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 ->
|
||||
|
|
|
@ -2,5 +2,4 @@ package eu.m724.vastapp.activity.dashboard
|
|||
|
||||
data class DashboardUiState(
|
||||
val refreshing: Int = 0
|
||||
) {
|
||||
}
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
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(modifier = Modifier.padding(8.dp)) {
|
||||
Text(label, fontSize = 22.sp)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.5f),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
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))
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.5f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(text = "GPU: $gpuUsage%", fontSize = 12.sp)
|
||||
LinearProgressIndicator(
|
||||
progress = { gpuUsage / 100f }
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Details about instance $label"
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
*/
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
5
app/src/main/res/drawable/copy_regular.xml
Normal file
5
app/src/main/res/drawable/copy_regular.xml
Normal 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>
|
28
app/src/main/res/drawable/termux_icon.xml
Normal file
28
app/src/main/res/drawable/termux_icon.xml
Normal 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>
|
|
@ -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>
|
Loading…
Reference in a new issue