Compare commits

..

12 commits

Author SHA1 Message Date
c5a401c1e5
make it translatable
and add a notice that you can't rent
2024-08-08 14:22:16 +02:00
a5d93da6ef
add instance delete dialog
commit granularity 🤷
2024-08-08 14:06:10 +02:00
a559f433db
add another helper method to rented instance data class 2024-08-08 14:05:43 +02:00
2fc83c4d5b
use these helper methods and tweak instance card 2024-08-08 13:54:47 +02:00
0b4bc2e524
make helper functions in rented instance data class 2024-08-08 13:53:59 +02:00
643006f9c4
fix instance actions 2024-08-08 13:53:40 +02:00
2ead3c054a
hide time card if no instances 2024-08-08 13:19:32 +02:00
bf240c4203
fix login via login screen 2024-08-08 13:14:29 +02:00
9c1741f769
optimize response handling 2024-08-08 13:14:18 +02:00
3e1631908a
we no longer need that 2024-08-08 12:06:30 +02:00
b0d86f0a67
prefill api key on login screen
if set of course
2024-08-08 12:06:02 +02:00
8dab1d7691
remove unnecessary space
that's commit granularity
2024-08-08 12:05:25 +02:00
11 changed files with 240 additions and 105 deletions

View file

@ -62,7 +62,6 @@ android {
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.material)

View file

@ -115,10 +115,10 @@ class DashboardViewModel(
fun toggleInstance(instance: RentedInstance) { fun toggleInstance(instance: RentedInstance) {
val deferred = val deferred =
if (instance.status == "running") { if (instance.isRunning()) {
vastApi.startInstance(instance.rentalId)
} else {
vastApi.stopInstance(instance.rentalId) vastApi.stopInstance(instance.rentalId)
} else {
vastApi.startInstance(instance.rentalId)
} }
viewModelScope.launch { viewModelScope.launch {
@ -144,5 +144,4 @@ class DashboardViewModel(
} }
} // TODO once again these methods share some code and more probably will so why not move the shared stuff } // TODO once again these methods share some code and more probably will so why not move the shared stuff
// OR not refresh but refresh only instances or even better don't refresh instances but delete or edit that one // OR not refresh but refresh only instances or even better don't refresh instances but delete or edit that one
} }

View file

@ -72,34 +72,21 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
// balance card // balance card
Card( BalanceCard(balance = user.credit, balanceWarning = user.balanceThreshold)
modifier = Modifier
.padding(16.dp), // time card
colors = CardDefaults.cardColors( if (rentedInstances.isNotEmpty())
containerColor = balanceCardColor(user.credit) RemainingTimeCard(remainingTime = remainingTime)
)
) { // instances
Row( InstancesCard(rentedInstancesCount = rentedInstances.size)
modifier = Modifier.padding(16.dp, 8.dp), }
verticalAlignment = Alignment.CenterVertically }
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.baseline_monetization_on_24),
contentDescription = stringResource(id = R.string.balance)
)
Spacer(
modifier = Modifier.width(12.dp)
)
Text(
text = "$%.2f".format(user.credit),
fontSize = 22.sp,
color = balanceColor(user.credit, user.balanceThreshold)
)
} }
} }
// time card @Composable
fun RemainingTimeCard(remainingTime: Int) {
Card( Card(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
@ -122,8 +109,40 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
) )
} }
} }
}
// instances @Composable
fun BalanceCard(balance: Double, balanceWarning: Double) {
Card(
modifier = Modifier
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = balanceCardColor(balance)
)
) {
Row(
modifier = Modifier.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.baseline_monetization_on_24),
contentDescription = stringResource(id = R.string.balance)
)
Spacer(
modifier = Modifier.width(12.dp)
)
Text(
text = "$%.2f".format(balance),
fontSize = 22.sp,
color = balanceColor(balance, balanceWarning)
)
}
}
}
@Composable
fun InstancesCard(rentedInstancesCount: Int) {
Card( Card(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
@ -141,15 +160,12 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
modifier = Modifier.width(12.dp) modifier = Modifier.width(12.dp)
) )
Text( Text(
text = rentedInstances.size.toString(), text = rentedInstancesCount.toString(),
fontSize = 22.sp fontSize = 22.sp
) )
} }
} }
} }
}
}
}
@Composable @Composable
fun balanceCardColor(balance: Double): Color { fun balanceCardColor(balance: Double): Color {

View file

@ -2,13 +2,14 @@ package eu.m724.vastapp.activity.dashboard.screen
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -18,14 +19,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -75,15 +79,19 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
) )
} }
} else { } else {
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(100.dp), .height(100.dp),
contentAlignment = Alignment.Center verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = stringResource(id = R.string.no_instances) text = stringResource(id = R.string.no_instances)
) )
Text(
text = stringResource(id = R.string.rent_on_website)
)
} }
} }
} }
@ -99,13 +107,34 @@ fun RentedInstanceCard(
deleteButtonClick: (RentedInstance) -> Unit, deleteButtonClick: (RentedInstance) -> Unit,
) { ) {
val instance by remember(rentedInstance) { derivedStateOf { rentedInstance.instance } } val instance by remember(rentedInstance) { derivedStateOf { rentedInstance.instance } }
val label by remember(instance) { derivedStateOf {
rentedInstance.label ?: instance.machine.gpu.model val dialogOpen = remember { mutableStateOf(false) }
} }
if (dialogOpen.value) {
InstanceDeleteDialog(
instance = rentedInstance,
onConfirm = {
dialogOpen.value = false
deleteButtonClick(rentedInstance)
},
onClose = { dialogOpen.value = false }
)
}
Card(modifier = modifier) { Card(modifier = modifier) {
Row(modifier = Modifier.padding(8.dp)) { Row(
Text(label, fontSize = 22.sp) modifier = Modifier
.height(IntrinsicSize.Min)
.padding(8.dp)
) {
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceEvenly // TODO I think the label is too low
) {
Text(rentedInstance.getName(), fontSize = 22.sp)
Text(rentedInstance.status, fontSize = 14.sp)
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Column( Column(
@ -115,7 +144,7 @@ fun RentedInstanceCard(
Button( Button(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
onClick = { deleteButtonClick(rentedInstance) }, onClick = { dialogOpen.value = true },
) { ) {
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
@ -130,9 +159,9 @@ fun RentedInstanceCard(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
onClick = { actionButtonClick(rentedInstance) }, onClick = { actionButtonClick(rentedInstance) },
enabled = rentedInstance.status == rentedInstance.targetStatus enabled = !rentedInstance.isChangingState()
) { ) {
if (rentedInstance.status == "running") { if (rentedInstance.isRunning()) {
Icon( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.baseline_stop_24), painter = painterResource(id = R.drawable.baseline_stop_24),
@ -177,9 +206,52 @@ fun RentedInstanceCard(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Details about instance $label" contentDescription = "Details about instance ${rentedInstance.getName()}"
) )
} }
} }
} }
} }
@Composable
fun InstanceDeleteDialog(
instance: RentedInstance,
onConfirm: () -> Unit,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = { onClose() },
confirmButton = {
TextButton(
onClick = { onConfirm() }
) {
Text("Confirm")
}
},
dismissButton = {
TextButton(
onClick = { onClose() }
) {
Text("Dismiss")
}
},
title = {
Text(
text = stringResource(
id = R.string.instance_confirm_delete
)
)
},
text = {
Text(
text = stringResource(
id = R.string.instance_confirm_delete_text,
instance.rentalId,
instance.getName()
)
)
},
)
}

View file

@ -60,8 +60,6 @@ import eu.m724.vastapp.activity.dashboard.DashboardActivity
import eu.m724.vastapp.ui.theme.VastappTheme import eu.m724.vastapp.ui.theme.VastappTheme
import eu.m724.vastapp.vastai.data.User import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine
import java.util.concurrent.Executors
class LoginActivity : ComponentActivity() { class LoginActivity : ComponentActivity() {
private lateinit var dashboardLauncher: ActivityResultLauncher<Intent> private lateinit var dashboardLauncher: ActivityResultLauncher<Intent>
@ -73,21 +71,25 @@ class LoginActivity : ComponentActivity() {
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { _ -> finish() } // TODO re-login here ) { _ -> finish() } // TODO re-login here
val executor = Executors.newSingleThreadExecutor() val loginViewModel = LoginViewModel(application)
val cronetEngine = CronetEngine.Builder(baseContext).build()
val loginViewModel = LoginViewModel(application, cronetEngine, executor)
// load api key if saved
loginViewModel.init()
// handle login errors
loginViewModel.error.observe(this) { errorMessage -> loginViewModel.error.observe(this) { errorMessage ->
if (errorMessage != null) { if (errorMessage != null) {
Toast.makeText(baseContext, errorMessage, Toast.LENGTH_SHORT).show() Toast.makeText(baseContext, errorMessage, Toast.LENGTH_SHORT).show()
} }
} }
// handle loading error
val loadingError = intent.getStringExtra("error") val loadingError = intent.getStringExtra("error")
if (loadingError != null) { if (loadingError != null) {
Toast.makeText(baseContext, loadingError, Toast.LENGTH_SHORT).show() Toast.makeText(baseContext, loadingError, Toast.LENGTH_SHORT).show()
} }
// load app if it's time
lifecycleScope.launch { // TODO I was suggested not to launch an activity from a lifecycle scope lifecycleScope.launch { // TODO I was suggested not to launch an activity from a lifecycle scope
loginViewModel.uiState.collect { state -> loginViewModel.uiState.collect { state ->
if (state is LoginUiState.Success) { if (state is LoginUiState.Success) {

View file

@ -5,19 +5,17 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import eu.m724.vastapp.BuildConfig
import eu.m724.vastapp.VastApplication import eu.m724.vastapp.VastApplication
import eu.m724.vastapp.vastai.Account
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine
import java.util.concurrent.Executor
class LoginViewModel( class LoginViewModel(
application: Application, application: Application
private val cronetEngine: CronetEngine,
private val executor: Executor
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val _uiState: MutableStateFlow<LoginUiState> = private val _uiState: MutableStateFlow<LoginUiState> =
MutableStateFlow(LoginUiState.Idle) MutableStateFlow(LoginUiState.Idle)
@ -32,13 +30,19 @@ class LoginViewModel(
private val application = getApplication<VastApplication>() private val application = getApplication<VastApplication>()
fun init() {
_apiKey.value = application.loadKey() ?: BuildConfig.VASTAI_KEY
}
fun tryLogin() { fun tryLogin() {
application.vastApi.setApiKey(apiKey.value)
val userDeferred = application.vastApi.getUser() val userDeferred = application.vastApi.getUser()
viewModelScope.launch { viewModelScope.launch {
try { try {
val user = userDeferred.await() val user = userDeferred.await()
application.submitKey(apiKey.value) // TODO toggle for this application.submitKey(apiKey.value) // TODO toggle for this
application.account = Account(user)
_uiState.update { LoginUiState.Success(user) } _uiState.update { LoginUiState.Success(user) }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { LoginUiState.Idle } _uiState.update { LoginUiState.Idle }

View file

@ -56,7 +56,7 @@ class VastApi(
val deferred = CompletableDeferred<Unit>() val deferred = CompletableDeferred<Unit>()
val request = requestMaker.buildRequest( val request = requestMaker.buildRequest(
"/instances/$rentalId", "/instances/$rentalId/", // THIS NEEDS A / AT THE END
JsonUrlRequestCallback( JsonUrlRequestCallback(
onSuccess = { onSuccess = {
if (it.getBoolean("success")) if (it.getBoolean("success"))
@ -77,7 +77,7 @@ class VastApi(
val deferred = CompletableDeferred<Unit>() val deferred = CompletableDeferred<Unit>()
val request = requestMaker.buildRequest( val request = requestMaker.buildRequest(
"/instances/$rentalId", "/instances/$rentalId/", // THIS NEEDS A / AT THE END
JsonUrlRequestCallback( JsonUrlRequestCallback(
onSuccess = { onSuccess = {
deferred.complete(Unit) deferred.complete(Unit)
@ -97,7 +97,7 @@ class VastApi(
val deferred = CompletableDeferred<Unit>() val deferred = CompletableDeferred<Unit>()
val request = requestMaker.buildRequest( val request = requestMaker.buildRequest(
"/instances/$rentalId", "/instances/$rentalId/", // THIS NEEDS A / AT THE END
JsonUrlRequestCallback( JsonUrlRequestCallback(
onSuccess = { onSuccess = {
deferred.complete(Unit) deferred.complete(Unit)

View file

@ -25,7 +25,8 @@ open class StringUrlRequestCallback(
} }
override fun onResponseStarted(request: UrlRequest?, info: UrlResponseInfo?) { override fun onResponseStarted(request: UrlRequest?, info: UrlResponseInfo?) {
request?.read(ByteBuffer.allocateDirect(102400)) val size = info?.allHeaders?.get("content-length")?.get(0)?.toIntOrNull()
request?.read(ByteBuffer.allocateDirect(size ?: 100000))
} }
override fun onReadCompleted( override fun onReadCompleted(
@ -36,13 +37,19 @@ open class StringUrlRequestCallback(
byteBuffer?.clear() byteBuffer?.clear()
request?.read(byteBuffer) request?.read(byteBuffer)
stringResponse.append(Charsets.UTF_8.newDecoder().onUnmappableCharacter(CodingErrorAction.IGNORE).decode(byteBuffer)) stringResponse.append(
Charsets.UTF_8.newDecoder()
.onUnmappableCharacter(CodingErrorAction.IGNORE)
.decode(byteBuffer)
)
} }
override fun onSucceeded(request: UrlRequest?, info: UrlResponseInfo?) { override fun onSucceeded(request: UrlRequest?, info: UrlResponseInfo?) {
if (info != null) { if (info != null) {
val body = stringResponse.toString() val body = stringResponse.toString()
val statusCode = info.httpStatusCode val statusCode = info.httpStatusCode
println(info.httpStatusCode.toString() + ": " + stringResponse.substring(0, stringResponse.length))
println(info.allHeaders.toString())
if (statusCode == 200) { if (statusCode == 200) {
try { try {
@ -52,7 +59,7 @@ open class StringUrlRequestCallback(
} }
} else if (statusCode >= 500) { } else if (statusCode >= 500) {
onFailure(ServerError(statusCode, body)) onFailure(ServerError(statusCode, body))
} else if (statusCode == 403) { } else if (statusCode == 401) {
onFailure(UnauthorizedException(body)) onFailure(UnauthorizedException(body))
} else { } else {
onFailure(ClientException(body)) onFailure(ClientException(body))

View file

@ -72,4 +72,34 @@ data class RentedInstance(
) )
} }
} }
/**
* gpu model or label if set
* @return instance name
*/
fun getName(): String {
return label ?: instance.machine.gpu.model
}
/**
* returns whether the instance is running
* this is true also if the instance is stopping
* vice versa, false if stopped or starting
* @return is the instance running
*/
fun isRunning(): Boolean {
return status == "running"
}
fun isStopping(): Boolean {
return status == "running" && targetStatus == "stopped"
}
fun isStarting(): Boolean {
return status == "exited" && targetStatus == "running"
}
fun isChangingState(): Boolean {
return isStopping() || isStarting()
}
} }

View file

@ -4,7 +4,7 @@
<string name="title_activity_login">Logowanie</string> <string name="title_activity_login">Logowanie</string>
<string name="nav_dashboard">Kokpit</string> <string name="nav_dashboard">Kokpit</string>
<string name="nav_billing">Płatności</string> <string name="nav_billing">Płatności</string>
<string name="nav_instances">Maszyny</string> <string name="nav_instances">Instancje</string>
<string name="nav_help">Pomoc</string> <string name="nav_help">Pomoc</string>
<string name="balance">Bilans</string> <string name="balance">Bilans</string>
<string name="greeting">Witaj %1$s!</string> <string name="greeting">Witaj %1$s!</string>
@ -27,7 +27,10 @@
<string name="termux_not_configured">Termux nie jest skonfigurowany pod działanie z innymi aplikacjami.</string> <string name="termux_not_configured">Termux nie jest skonfigurowany pod działanie z innymi aplikacjami.</string>
<string name="termux_open_instructions">Otwórz instrukcje na github.com</string> <string name="termux_open_instructions">Otwórz instrukcje na github.com</string>
<string name="termux_error">Wystąpił błąd:</string> <string name="termux_error">Wystąpił błąd:</string>
<string name="no_instances">Nie wynajmujesz żadnych maszyn</string> <string name="no_instances">Nie wynajmujesz żadnych instancji</string>
<string name="webview_todo">(kiedyś to będzie tutaj)</string> <string name="webview_todo">(kiedyś to będzie tutaj)</string>
<string name="termux_no_ssh">Brakuje klienta SSH na Termux</string> <string name="termux_no_ssh">Brakuje klienta SSH na Termux</string>
<string name="rent_on_website">Jeszcze nie możesz wynajmować z tej aplikacji</string>
<string name="instance_confirm_delete">Potwierdź usunięcie</string>
<string name="instance_confirm_delete_text">Instancja #%1$d (%2$s)</string>
</resources> </resources>

View file

@ -31,4 +31,7 @@
<string name="webview_todo">(this will be a webview)</string> <string name="webview_todo">(this will be a webview)</string>
<string name="no_instances">You are not renting any instances</string> <string name="no_instances">You are not renting any instances</string>
<string name="termux_no_ssh">Missing SSH client on Termux</string> <string name="termux_no_ssh">Missing SSH client on Termux</string>
<string name="rent_on_website">You can\'t rent from this app yet</string>
<string name="instance_confirm_delete">Confirm deletion</string>
<string name="instance_confirm_delete_text">Instance #%1$d (%2$s)</string>
</resources> </resources>