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 {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)

View file

@ -115,10 +115,10 @@ class DashboardViewModel(
fun toggleInstance(instance: RentedInstance) {
val deferred =
if (instance.status == "running") {
vastApi.startInstance(instance.rentalId)
} else {
if (instance.isRunning()) {
vastApi.stopInstance(instance.rentalId)
} else {
vastApi.startInstance(instance.rentalId)
}
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
// 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
) {
// balance card
Card(
modifier = Modifier
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = balanceCardColor(user.credit)
)
) {
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(user.credit),
fontSize = 22.sp,
color = balanceColor(user.credit, user.balanceThreshold)
)
}
}
BalanceCard(balance = user.credit, balanceWarning = user.balanceThreshold)
// time card
if (rentedInstances.isNotEmpty())
RemainingTimeCard(remainingTime = remainingTime)
// instances
InstancesCard(rentedInstancesCount = rentedInstances.size)
}
}
}
}
@Composable
fun RemainingTimeCard(remainingTime: Int) {
Card(
modifier = Modifier
.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(
modifier = Modifier
.padding(16.dp)
@ -141,14 +160,11 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
modifier = Modifier.width(12.dp)
)
Text(
text = rentedInstances.size.toString(),
text = rentedInstancesCount.toString(),
fontSize = 22.sp
)
}
}
}
}
}
}
@Composable

View file

@ -2,13 +2,14 @@ package eu.m724.vastapp.activity.dashboard.screen
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -18,14 +19,17 @@ 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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -75,15 +79,19 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
)
}
} else {
Box(
Column(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
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,
) {
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) {
Row(modifier = Modifier.padding(8.dp)) {
Text(label, fontSize = 22.sp)
Row(
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))
Column(
@ -115,7 +144,7 @@ fun RentedInstanceCard(
Button(
modifier = Modifier.size(24.dp),
contentPadding = PaddingValues(0.dp),
onClick = { deleteButtonClick(rentedInstance) },
onClick = { dialogOpen.value = true },
) {
Icon(
modifier = Modifier.size(16.dp),
@ -130,9 +159,9 @@ fun RentedInstanceCard(
modifier = Modifier.size(24.dp),
contentPadding = PaddingValues(0.dp),
onClick = { actionButtonClick(rentedInstance) },
enabled = rentedInstance.status == rentedInstance.targetStatus
enabled = !rentedInstance.isChangingState()
) {
if (rentedInstance.status == "running") {
if (rentedInstance.isRunning()) {
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.baseline_stop_24),
@ -177,9 +206,52 @@ fun RentedInstanceCard(
Spacer(modifier = Modifier.height(6.dp))
Icon(
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.vastai.data.User
import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine
import java.util.concurrent.Executors
class LoginActivity : ComponentActivity() {
private lateinit var dashboardLauncher: ActivityResultLauncher<Intent>
@ -73,21 +71,25 @@ class LoginActivity : ComponentActivity() {
ActivityResultContracts.StartActivityForResult()
) { _ -> finish() } // TODO re-login here
val executor = Executors.newSingleThreadExecutor()
val cronetEngine = CronetEngine.Builder(baseContext).build()
val loginViewModel = LoginViewModel(application, cronetEngine, executor)
val loginViewModel = LoginViewModel(application)
// load api key if saved
loginViewModel.init()
// handle login errors
loginViewModel.error.observe(this) { errorMessage ->
if (errorMessage != null) {
Toast.makeText(baseContext, errorMessage, Toast.LENGTH_SHORT).show()
}
}
// handle loading error
val loadingError = intent.getStringExtra("error")
if (loadingError != null) {
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
loginViewModel.uiState.collect { state ->
if (state is LoginUiState.Success) {

View file

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

View file

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

View file

@ -25,7 +25,8 @@ open class StringUrlRequestCallback(
}
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(
@ -36,13 +37,19 @@ open class StringUrlRequestCallback(
byteBuffer?.clear()
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?) {
if (info != null) {
val body = stringResponse.toString()
val statusCode = info.httpStatusCode
println(info.httpStatusCode.toString() + ": " + stringResponse.substring(0, stringResponse.length))
println(info.allHeaders.toString())
if (statusCode == 200) {
try {
@ -52,7 +59,7 @@ open class StringUrlRequestCallback(
}
} else if (statusCode >= 500) {
onFailure(ServerError(statusCode, body))
} else if (statusCode == 403) {
} else if (statusCode == 401) {
onFailure(UnauthorizedException(body))
} else {
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="nav_dashboard">Kokpit</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="balance">Bilans</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_open_instructions">Otwórz instrukcje na github.com</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="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>

View file

@ -31,4 +31,7 @@
<string name="webview_todo">(this will be a webview)</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="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>