super ultra mega refactoring

- now extending Application
- using that Application to store data like user or instances
- completed loading screen
  * made loading seamless
- and made other stuff work with that
This commit is contained in:
Minecon724 2024-08-05 14:11:53 +02:00
parent 9574ed496d
commit ae4912fd29
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
19 changed files with 395 additions and 106 deletions

View file

@ -14,7 +14,19 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Vastapp"
android:name=".VastApplication"
tools:targetApi="34">
<activity
android:name=".activity.dashboard.loading.LoadingActivity"
android:exported="true"
android:label="@string/title_activity_loading"
android:theme="@style/Theme.Vastapp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activity.termux.TermuxSshActivity"
android:exported="false"
@ -30,11 +42,6 @@
android:exported="true"
android:label="@string/title_activity_login"
android:theme="@style/Theme.Vastapp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

View file

@ -0,0 +1,38 @@
package eu.m724.vastapp
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.m724.vastapp.vastai.Account
import eu.m724.vastapp.vastai.VastApi
import org.chromium.net.CronetEngine
import java.util.concurrent.Executors
class VastApplication : Application() {
private lateinit var cronetEngine: CronetEngine
lateinit var vastApi: VastApi // TODO maybe make private?
private lateinit var loginSharedPreferences: SharedPreferences
var account: Account? = null
override fun onCreate() {
super.onCreate()
cronetEngine = CronetEngine.Builder(baseContext)
.enableBrotli(true)
.build() // http3 is not supported on cloud.vast.ai
vastApi = VastApi("", cronetEngine, Executors.newSingleThreadExecutor())
loginSharedPreferences = applicationContext.getSharedPreferences("login", Context.MODE_PRIVATE)
}
fun submitKey(apiKey: String) {
with (loginSharedPreferences.edit()) {
putString("apiKey", apiKey) // TODO encrypt
apply()
}
}
fun loadKey(): String? {
return loginSharedPreferences.getString("apiKey", null)
}
}

View file

@ -36,26 +36,18 @@ import eu.m724.vastapp.activity.dashboard.screen.HelpScreen
import eu.m724.vastapp.activity.dashboard.screen.InstancesScreen
import eu.m724.vastapp.activity.dashboard.screen.Screen
import eu.m724.vastapp.ui.theme.VastappTheme
import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine
import java.util.concurrent.Executors
class DashboardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = intent.getParcelableExtra<User>("user")!! // TODO null check
val executor = Executors.newSingleThreadExecutor()
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)
val dashboardViewModel = DashboardViewModel(application)
if (intent.getBooleanExtra("direct", false).not()) {
dashboardViewModel.refresh(this)
}
lifecycleScope.launch {
dashboardViewModel.refresh(this@DashboardActivity)
repeatOnLifecycle(Lifecycle.State.STARTED) {
dashboardViewModel.refreshError.collect {
it.forEach { errorMsg ->

View file

@ -1,19 +1,19 @@
package eu.m724.vastapp.activity.dashboard
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import eu.m724.vastapp.R
import eu.m724.vastapp.VastApplication
import eu.m724.vastapp.activity.Opener
import eu.m724.vastapp.activity.PermissionChecker
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.RentedInstance
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -21,30 +21,24 @@ import kotlinx.coroutines.flow.update
class DashboardViewModel(
initialUser: User,
private val vastApi: VastApi
) : ViewModel() { // TODO do something with the user
application: Application
) : AndroidViewModel(application) { // 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<RentedInstance>> = MutableStateFlow(emptyList())
val rentedInstances: StateFlow<List<RentedInstance>> = _rentedInstances.asStateFlow()
private val _user: MutableStateFlow<User> = MutableStateFlow(initialUser)
val user: StateFlow<User> = _user.asStateFlow()
private val _remainingTime: MutableStateFlow<Int> = MutableStateFlow(-1)
var remainingTime: StateFlow<Int> = _remainingTime.asStateFlow()
private val _refreshError: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
val refreshError: StateFlow<List<String>> = _refreshError.asStateFlow()
private val _termuxAvailable: MutableStateFlow<Int> = MutableStateFlow(0)
val termuxAvailable: StateFlow<Int> = _termuxAvailable.asStateFlow()
private val application = getApplication<VastApplication>()
private val vastApi = this.application.vastApi
val account = this.application.account!!
fun refresh(activity: ComponentActivity) {
_uiState.value = _uiState.value.copy(refreshing = 2)
_refreshError.value = emptyList()
@ -52,7 +46,7 @@ class DashboardViewModel(
val userRequest = vastApi.buildRequest(
ApiRoute.SHOW_USER,
UserUrlRequestCallback({ newUser ->
_user.value = newUser
account.updateUser(newUser)
_uiState.update {
it.copy(refreshing = it.refreshing - 1) // TODO I don't like how this looks
}
@ -67,33 +61,17 @@ class DashboardViewModel(
val instancesRequest = vastApi.buildRequest(
ApiRoute.GET_INSTANCES,
InstancesUrlRequestCallback({ instances ->
_rentedInstances.value = instances // TODO better way?
account.updateRentedInstances(instances)
_uiState.update {
it.copy(refreshing = it.refreshing - 1)
}
if (instances.isEmpty()) { // TODO move this
_remainingTime.value = -1
} else {
var totalDph = 0.0
instances.forEach {
if (it.status == "running")
totalDph += it.instance.pricing.dphTotal!!
else
totalDph += it.instance.pricing.dphTotal!! - it.instance.pricing.dphBase
// TODO make this ideal
}
_remainingTime.value = (user.value.credit / totalDph * 3600).toInt()
}
}, { apiFailure ->
_refreshError.update { it + apiFailure.errorMessage!! }
_uiState.update {
it.copy(refreshing = it.refreshing - 1)
}
})
)
) // TODO move all that refreshing to some shared place
userRequest.start()
instancesRequest.start()
@ -111,7 +89,7 @@ class DashboardViewModel(
}
} 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. I think it should be moved to application
}
@SuppressLint("SdCardPath")
@ -123,7 +101,7 @@ class DashboardViewModel(
PermissionChecker.requestIfNoPermission(
"com.termux.permission.RUN_COMMAND",
activity, 0
) { granted, asked ->
) { granted, _ ->
if (granted) {
val arguments = arrayOf(
"/data/data/com.termux/files/usr/bin/ssh",

View file

@ -0,0 +1,76 @@
package eu.m724.vastapp.activity.dashboard.loading
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import eu.m724.vastapp.R
import eu.m724.vastapp.activity.dashboard.DashboardActivity
import eu.m724.vastapp.activity.dashboard.loading.ui.theme.VastappTheme
import eu.m724.vastapp.activity.login.LoginActivity
class LoadingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = LoadingViewModel(application) { result ->
println(result.loggedIn)
val intent =
if (result.loggedIn) {
Intent(this, DashboardActivity::class.java)
.putExtra("direct", true)
} else {
Intent(this, LoginActivity::class.java)
.putExtra("error", result.error!!)
}
this.startActivity(intent)
finish()
}
viewModel.init()
enableEdgeToEdge()
setContent {
VastappTheme {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
modifier = Modifier.fillMaxSize(),
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "app icon"
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
}

View file

@ -0,0 +1,6 @@
package eu.m724.vastapp.activity.dashboard.loading
data class LoadingResult(
val loggedIn: Boolean,
val error: String?
)

View file

@ -0,0 +1,54 @@
package eu.m724.vastapp.activity.dashboard.loading
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import eu.m724.vastapp.VastApplication
import eu.m724.vastapp.vastai.Account
import eu.m724.vastapp.vastai.ApiRoute
import eu.m724.vastapp.vastai.api.InstancesUrlRequestCallback
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
class LoadingViewModel(
application: Application,
private val onEnded: (LoadingResult) -> Unit,
) : AndroidViewModel(application) {
private val application = application as VastApplication
fun init() {
val vastApi = application.vastApi
val apiKey = application.loadKey()
if (apiKey != null) {
vastApi.apiKey = apiKey
val request = vastApi.buildRequest(
ApiRoute.SHOW_USER,
UserUrlRequestCallback({ user ->
application.account = Account(user)
loadInstances()
}, { apiFailure ->
onEnded(LoadingResult(false, apiFailure.errorMessage))
})
)
request.start()
}
}
private fun loadInstances() {
val vastApi = application.vastApi
val instancesRequest = vastApi.buildRequest(
ApiRoute.GET_INSTANCES,
InstancesUrlRequestCallback({ instances ->
application.account!!.updateRentedInstances(instances)
onEnded(LoadingResult(true, null))
}, { apiFailure ->
// TODO I don't know what to do yet
onEnded(LoadingResult(true, null))
})
)
instancesRequest.start()
}
}

View file

@ -0,0 +1,11 @@
package eu.m724.vastapp.activity.dashboard.loading.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,58 @@
package eu.m724.vastapp.activity.dashboard.loading.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun VastappTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,34 @@
package eu.m724.vastapp.activity.dashboard.loading.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

@ -29,7 +29,7 @@ import eu.m724.vastapp.activity.dashboard.DashboardViewModel
fun BillingScreen(dashboardViewModel: DashboardViewModel) {
val uiState by dashboardViewModel.uiState.collectAsState()
val user by dashboardViewModel.user.collectAsState()
val user by dashboardViewModel.account.user.collectAsState()
Column(
modifier = Modifier.fillMaxWidth(),

View file

@ -44,9 +44,9 @@ fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
val context = LocalContext.current
val uiState by dashboardViewModel.uiState.collectAsState()
val user by dashboardViewModel.user.collectAsState()
val rentedInstances by dashboardViewModel.rentedInstances.collectAsState()
val remainingTime by dashboardViewModel.remainingTime.collectAsState()
val user by dashboardViewModel.account.user.collectAsState()
val rentedInstances by dashboardViewModel.account.rentedInstances.collectAsState()
val remainingTime by dashboardViewModel.account.remainingTime.collectAsState()
val isRefreshing by remember(uiState) { derivedStateOf { uiState.refreshing > 0 } }
val scrollState = rememberScrollState()

View file

@ -42,7 +42,7 @@ fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
val activity = LocalContext.current as ComponentActivity
val uiState by dashboardViewModel.uiState.collectAsState()
val rentedInstances by dashboardViewModel.rentedInstances.collectAsState()
val rentedInstances by dashboardViewModel.account.rentedInstances.collectAsState()
val termuxAvailable by dashboardViewModel.termuxAvailable.collectAsState()
// TODO actually get instances

View file

@ -55,7 +55,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import eu.m724.vastapp.BuildConfig
import eu.m724.vastapp.R
import eu.m724.vastapp.activity.dashboard.DashboardActivity
import eu.m724.vastapp.ui.theme.VastappTheme
@ -84,6 +83,11 @@ class LoginActivity : ComponentActivity() {
}
}
val loadingError = intent.getStringExtra("error")
if (loadingError != null) {
Toast.makeText(baseContext, loadingError, Toast.LENGTH_SHORT).show()
}
lifecycleScope.launch { // TODO I was suggested not to launch an activity from a lifecycle scope
loginViewModel.uiState.collect { state ->
if (state is LoginUiState.Success) {
@ -92,9 +96,6 @@ class LoginActivity : ComponentActivity() {
}
}
if (BuildConfig.AUTO_LOGIN)
loginViewModel.loadKey()
enableEdgeToEdge()
setContent {
VastappTheme {

View file

@ -1,12 +1,11 @@
package eu.m724.vastapp.activity.login
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import eu.m724.vastapp.VastApplication
import eu.m724.vastapp.vastai.ApiRoute
import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -34,35 +33,16 @@ class LoginViewModel(
private val _fullscreenLoading = MutableStateFlow<Boolean>(false)
var fullscreenLoading: StateFlow<Boolean> = _fullscreenLoading.asStateFlow()
private val applicationContext = getApplication<Application>().applicationContext
private val sharedPreferences = applicationContext.getSharedPreferences("login", Context.MODE_PRIVATE)
private fun saveKey() {
with (sharedPreferences.edit()) {
putString("apiKey", apiKey.value) // TODO encrypt
apply()
}
}
fun loadKey() {
val apiKey = sharedPreferences.getString("apiKey", null)
if (apiKey != null) {
_apiKey.value = apiKey
_fullscreenLoading.value = true
tryLogin()
}
}
private val application = getApplication<VastApplication>()
fun tryLogin() {
val apiKey = apiKey.value
val vastApi = application.vastApi
vastApi.apiKey = apiKey.value
val vastApi = VastApi(apiKey, cronetEngine, executor)
val request = vastApi.buildRequest(
ApiRoute.SHOW_USER,
UserUrlRequestCallback({ user ->
saveKey() // TODO toggle for this
application.submitKey(apiKey.value) // TODO toggle for this
_uiState.value = LoginUiState.Success(user)
}, { apiFailure ->
_uiState.value = LoginUiState.Idle

View file

@ -0,0 +1,49 @@
package eu.m724.vastapp.vastai
import eu.m724.vastapp.vastai.data.RentedInstance
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class Account(
initialUser: User
) {
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()
private val _remainingTime: MutableStateFlow<Int> = MutableStateFlow(-1)
var remainingTime: StateFlow<Int> = _remainingTime.asStateFlow()
fun updateUser(user: User) {
_user.value = user
}
fun updateRentedInstances(rentedInstances: List<RentedInstance>) { // TODO better way?
_rentedInstances.value = rentedInstances
calculateRemainingTime()
}
fun calculateRemainingTime() {
val rentedInstances = rentedInstances.value
if (rentedInstances.isEmpty()) {
_remainingTime.value = -1
} else {
var totalDph = 0.0
rentedInstances.forEach {
if (it.status == "running")
totalDph += it.instance.pricing.dphTotal!!
else
totalDph += it.instance.pricing.dphTotal!! - it.instance.pricing.dphBase
// TODO make this ideal
}
_remainingTime.value = (user.value.credit / totalDph * 3600).toInt()
}
}
}

View file

@ -1,16 +1,14 @@
package eu.m724.vastapp.vastai
import android.os.Parcel
import android.os.Parcelable
import eu.m724.vastapp.BuildConfig
import org.chromium.net.CronetEngine
import org.chromium.net.UrlRequest
import java.util.concurrent.Executor
class VastApi(
val apiKey: String,
val cronetEngine: CronetEngine,
val executor: Executor
var apiKey: String, // TODO make private?
private val cronetEngine: CronetEngine,
private val executor: Executor
) {
/**
* build an api request

View file

@ -43,16 +43,23 @@ class UserUrlRequestCallback(
override fun onSucceeded(request: UrlRequest?, info: UrlResponseInfo?) {
println(stringResponse) // TODO don't do that
if (info?.httpStatusCode == 200) {
val jsonResponse = JSONObject(stringResponse.toString())
onSuccess(User(
id = jsonResponse.getString("id"),
username = jsonResponse.getString("username"),
email = jsonResponse.getString("email"),
apiKey = jsonResponse.getString("api_key"),
credit = jsonResponse.getDouble("credit"),
balanceThreshold = jsonResponse.getDouble("balance_threshold"),
balanceThresholdEnabled = jsonResponse.getBoolean("balance_threshold_enabled"),
))
try {
val jsonResponse = JSONObject(stringResponse.toString())
onSuccess(
User(
id = jsonResponse.getString("id"),
username = jsonResponse.getString("username"),
email = jsonResponse.getString("email"),
apiKey = jsonResponse.getString("api_key"),
credit = jsonResponse.getDouble("credit"),
balanceThreshold = jsonResponse.getDouble("balance_threshold"),
balanceThresholdEnabled = jsonResponse.getBoolean("balance_threshold_enabled"),
)
)
} catch (e: Exception) {
onFailure(ApiFailure(e.message))
println("API response error: $stringResponse")
}
} else {
onFailure(ApiFailure("${info?.httpStatusCode} ${info?.httpStatusText}"))
println("API error: $stringResponse")

View file

@ -1,7 +1,8 @@
<resources>
<string name="app_name" translatable="false">vast.app</string>
<string name="title_activity_dashboard">Dashboard</string>
<string name="title_activity_login" translatable="false">vast.app</string>
<string name="title_activity_login">Login</string>
<string name="title_activity_loading" translatable="false">vast.app</string>
<string name="nav_dashboard">Dashboard</string>
<string name="nav_billing">Billing</string>
<string name="nav_instances">Instances</string>
@ -21,14 +22,13 @@
<string name="login_checkbox_angry">checkbox is angry</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="no_termux">Termuxn\'t</string>
<string name="title_activity_termux_ssh">TermuxSshActivity</string>
<string name="title_activity_termux_ssh">Termux Error</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="termux_error">An error occurred:</string>
<string name="webview_todo">(this will be a webview)</string>
</resources>