mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 08:19:33 +00:00
Implement built in updater (#6)
* implement release check api * add options for updater * fix settings scroll * minor preference reorganization * implement initial release properties * add downloading code * change download api * add extraction code * fix modifier padding * update geode status after update * set stored release after update * disable updater by default for armv8 * add settings toasts * use snackbar instead of toast * small cleanup * remove unused constant * implement stable channel * child doesn't require viewmodel * update release format * enable updater by default for all archs * modify updater to be global * fix copyfile reference * correct state ordering issue * only allow one update at a time * show message in settings on second update * allow update cancellation and retry * disable start until update finished * fix countdown lifecycle * always sync release state to main activity * allow scrolling main activity * use a library for internal downloads mat allowed me to * show download progress in settings * no internet messages * enable api request cache * delete old library before extracting * oops....
This commit is contained in:
1 parent
c421e37e1a
commit
4c555881b5
17 files changed
+1184
-209
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -65,10 +66,16 @@ dependencies {
|
|||||||
implementation 'androidx.compose.material3:material3:1.1.2'
|
implementation 'androidx.compose.material3:material3:1.1.2'
|
||||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||||
|
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"
|
||||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
implementation 'androidx.activity:activity-ktx:1.8.2'
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation 'com.google.android.material:material:1.11.0'
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation "com.squareup.okio:okio:3.7.0"
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.5.0'
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.0"
|
||||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
|
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
|
||||||
}
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.geode.launcher
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.AsyncTask
|
|
||||||
import android.util.Log
|
|
||||||
import com.geode.launcher.MainActivity
|
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
|
|
||||||
class DownloadGeode constructor(context: Context) : AsyncTask<String, Int, Boolean>() {
|
|
||||||
private val context: Context = context
|
|
||||||
|
|
||||||
override fun doInBackground(vararg params: String?): Boolean {
|
|
||||||
val url = URL(params[0])
|
|
||||||
val connection = url.openConnection() as HttpURLConnection
|
|
||||||
connection.requestMethod = "GET"
|
|
||||||
connection.connect()
|
|
||||||
// downloads a zip file
|
|
||||||
ZipInputStream(connection.inputStream).use { zip ->
|
|
||||||
var entry = zip.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
val file = entry.name
|
|
||||||
context.getExternalFilesDir(null)?.let { dir->
|
|
||||||
val destFile = File(dir, file)
|
|
||||||
destFile.outputStream().use { output ->
|
|
||||||
zip.copyTo(output)
|
|
||||||
Log.d("Geode", "Downloading to $destFile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry = zip.nextEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPostExecute(result: Boolean) {
|
|
||||||
super.onPostExecute(result)
|
|
||||||
Log.d("Geode", "Downloading Result: $result")
|
|
||||||
|
|
||||||
// update the screen
|
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,8 +23,10 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.GeodeUtils
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import org.cocos2dx.lib.Cocos2dxEditText
|
import org.cocos2dx.lib.Cocos2dxEditText
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxHelper
|
import org.cocos2dx.lib.Cocos2dxHelper
|
||||||
@@ -33,8 +35,6 @@ import org.fmod.FMOD
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
|
|
||||||
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
|
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
|
||||||
@@ -149,7 +149,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
// there doesn't seem to be a way to load a library from a file descriptor
|
// there doesn't seem to be a way to load a library from a file descriptor
|
||||||
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
val libraryOutput = libraryCopy.outputStream()
|
val libraryOutput = libraryCopy.outputStream()
|
||||||
copyFile(libraryFd.createInputStream(), libraryOutput)
|
DownloadUtils.copyFile(libraryFd.createInputStream(), libraryOutput)
|
||||||
|
|
||||||
System.load(libraryCopy.path)
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
@@ -190,7 +190,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return true
|
return true
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
// but users may prefer it stored with data
|
// but users may prefer it stored with data
|
||||||
val geodePath = File(filesDir.path, "launcher/Geode.so")
|
val geodeFilename = LaunchUtils.getGeodeFilename()
|
||||||
|
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||||
if (geodePath.exists()) {
|
if (geodePath.exists()) {
|
||||||
System.load(geodePath.path)
|
System.load(geodePath.path)
|
||||||
return true
|
return true
|
||||||
@@ -211,7 +212,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
val geodePath = File(copiedPath.path, "Geode.so")
|
val geodePath = File(copiedPath.path, "Geode.so")
|
||||||
|
|
||||||
if (externalGeodePath.exists()) {
|
if (externalGeodePath.exists()) {
|
||||||
copyFile(FileInputStream(externalGeodePath), FileOutputStream(geodePath))
|
DownloadUtils.copyFile(
|
||||||
|
FileInputStream(externalGeodePath),
|
||||||
|
FileOutputStream(geodePath)
|
||||||
|
)
|
||||||
|
|
||||||
if (geodePath.exists()) {
|
if (geodePath.exists()) {
|
||||||
try {
|
try {
|
||||||
@@ -395,9 +399,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getLoadTesting(): Boolean {
|
private fun getLoadTesting(): Boolean {
|
||||||
val preferences = getSharedPreferences(getString(R.string.preference_file_key), Context.MODE_PRIVATE)
|
val preferences = PreferenceUtils.get(this)
|
||||||
|
return preferences.getBoolean(PreferenceUtils.Key.LOAD_TESTING)
|
||||||
return preferences.getBoolean(getString(R.string.preference_load_testing), false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
@@ -416,7 +419,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
if (it.isFile) {
|
if (it.isFile) {
|
||||||
// welcome to the world of Android classloader permissions
|
// welcome to the world of Android classloader permissions
|
||||||
val outputFile = File(testDirPath.path + File.separator + it.name)
|
val outputFile = File(testDirPath.path + File.separator + it.name)
|
||||||
copyFile(FileInputStream(it), FileOutputStream(outputFile))
|
DownloadUtils.copyFile(
|
||||||
|
FileInputStream(it),
|
||||||
|
FileOutputStream(outputFile)
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
println("Loading test library ${outputFile.name}")
|
println("Loading test library ${outputFile.name}")
|
||||||
@@ -428,23 +434,4 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
|
||||||
// gotta love copying
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
FileUtils.copy(inputStream, outputStream)
|
|
||||||
} else {
|
|
||||||
inputStream.use { input ->
|
|
||||||
outputStream.use { output ->
|
|
||||||
val buffer = ByteArray(4 * 1024)
|
|
||||||
while (true) {
|
|
||||||
val byteCount = input.read(buffer)
|
|
||||||
if (byteCount < 0) break
|
|
||||||
output.write(buffer, 0, byteCount)
|
|
||||||
}
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,28 +3,41 @@ package com.geode.launcher
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.api.ReleaseViewModel
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
import com.geode.launcher.ui.theme.Typography
|
||||||
import com.geode.launcher.utils.Constants
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import com.geode.launcher.utils.useCountdownTimer
|
import com.geode.launcher.utils.useCountdownTimer
|
||||||
import com.geode.launcher.utils.usePreference
|
import java.net.ConnectException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -51,18 +64,149 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
if (gdInstalled && !geodeInstalled) {
|
|
||||||
downloadGeode(this)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadGeode(context: Context) {
|
@Composable
|
||||||
// download geode in the background and update the screen when it's done
|
fun UpdateProgressIndicator(
|
||||||
DownloadGeode(context).execute(Constants.GEODE_DOWNLOAD_LINK)
|
message: String,
|
||||||
|
releaseViewModel: ReleaseViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: Float? = null
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(message)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(4.dp))
|
||||||
|
|
||||||
|
if (progress == null) {
|
||||||
|
LinearProgressIndicator()
|
||||||
|
} else {
|
||||||
|
LinearProgressIndicator(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
releaseViewModel.cancelUpdate()
|
||||||
|
},
|
||||||
|
modifier = Modifier.offset((-12).dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.release_fetch_button_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateMessageIndicator(
|
||||||
|
message: String,
|
||||||
|
releaseViewModel: ReleaseViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
allowRetry: Boolean = false
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(4.dp))
|
||||||
|
|
||||||
|
if (allowRetry) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
releaseViewModel.runReleaseCheck()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.release_fetch_button_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val releaseState by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
when (val state = releaseState) {
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||||
|
val message = when (state.exception) {
|
||||||
|
is UnknownHostException, is ConnectException ->
|
||||||
|
stringResource(R.string.release_fetch_no_internet)
|
||||||
|
else -> state.exception.message
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateMessageIndicator(
|
||||||
|
stringResource(R.string.release_fetch_failed, message ?: ""),
|
||||||
|
modifier = modifier,
|
||||||
|
allowRetry = true,
|
||||||
|
releaseViewModel = releaseViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||||
|
val progress = state.downloaded / state.outOf.toDouble()
|
||||||
|
|
||||||
|
val downloaded = remember(state.downloaded) {
|
||||||
|
formatShortFileSize(context, state.downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
val outOf = remember(state.outOf) {
|
||||||
|
formatShortFileSize(context, state.outOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateProgressIndicator(
|
||||||
|
stringResource(
|
||||||
|
R.string.release_fetch_downloading,
|
||||||
|
downloaded,
|
||||||
|
outOf
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
releaseViewModel = releaseViewModel,
|
||||||
|
progress = progress.toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||||
|
UpdateProgressIndicator(
|
||||||
|
stringResource(R.string.release_fetch_in_progress),
|
||||||
|
modifier = modifier,
|
||||||
|
releaseViewModel = releaseViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||||
|
if (state.hasUpdated) {
|
||||||
|
UpdateMessageIndicator(
|
||||||
|
stringResource(R.string.release_fetch_success),
|
||||||
|
modifier = modifier,
|
||||||
|
releaseViewModel = releaseViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
|
||||||
|
if (state.isCancelling) {
|
||||||
|
UpdateProgressIndicator(
|
||||||
|
stringResource(R.string.release_fetch_cancelling),
|
||||||
|
modifier = modifier,
|
||||||
|
releaseViewModel = releaseViewModel
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
UpdateMessageIndicator(
|
||||||
|
stringResource(R.string.release_fetch_cancelled),
|
||||||
|
modifier = modifier,
|
||||||
|
allowRetry = true,
|
||||||
|
releaseViewModel = releaseViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onLaunch(context: Context) {
|
fun onLaunch(context: Context) {
|
||||||
@@ -78,16 +222,37 @@ fun onSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
|
fun MainScreen(
|
||||||
|
gdInstalled: Boolean = true,
|
||||||
|
geodePreinstalled: Boolean = true,
|
||||||
|
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val shouldAutomaticallyLaunch = usePreference(
|
val shouldAutomaticallyLaunch = PreferenceUtils.useBooleanPreference(
|
||||||
preferenceFileKey = R.string.preference_file_key,
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||||
preferenceId = R.string.preference_load_automatically
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
|
||||||
|
val autoUpdateState by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
|
||||||
|
?.hasUpdated ?: false
|
||||||
|
val geodeInstalled = geodePreinstalled || geodeJustInstalled
|
||||||
|
|
||||||
|
LaunchedEffect(shouldUpdate) {
|
||||||
|
if (shouldUpdate && !releaseViewModel.hasPerformedCheck) {
|
||||||
|
releaseViewModel.runReleaseCheck()
|
||||||
|
} else {
|
||||||
|
releaseViewModel.useGlobalCheckState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -101,8 +266,9 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
|
|||||||
fontSize = 32.sp,
|
fontSize = 32.sp,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (gdInstalled && geodeInstalled) {
|
if (gdInstalled && geodeInstalled) {
|
||||||
if (shouldAutomaticallyLaunch.value) {
|
if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate) {
|
||||||
val countdownTimer = useCountdownTimer(
|
val countdownTimer = useCountdownTimer(
|
||||||
time = 3000,
|
time = 3000,
|
||||||
onCountdownFinish = {
|
onCountdownFinish = {
|
||||||
@@ -113,10 +279,12 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (countdownTimer.value != 0) {
|
if (countdownTimer != 0L) {
|
||||||
Text(
|
Text(
|
||||||
context.resources.getQuantityString(
|
pluralStringResource(
|
||||||
R.plurals.automatically_load_countdown, countdownTimer.value, countdownTimer.value
|
R.plurals.automatically_load_countdown,
|
||||||
|
countdownTimer.toInt(),
|
||||||
|
countdownTimer
|
||||||
),
|
),
|
||||||
style = Typography.bodyMedium,
|
style = Typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
@@ -126,7 +294,10 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
Button(onClick = { onLaunch(context) }) {
|
Button(
|
||||||
|
onClick = { onLaunch(context) },
|
||||||
|
enabled = !releaseViewModel.isInUpdate
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.PlayArrow,
|
Icons.Filled.PlayArrow,
|
||||||
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
|
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
|
||||||
@@ -169,6 +340,11 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
|
|||||||
Text(context.getString(R.string.launcher_settings))
|
Text(context.getString(R.string.launcher_settings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateCard(
|
||||||
|
releaseViewModel,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,26 +9,36 @@ import android.widget.Toast
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedDispatcher
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.api.ReleaseViewModel
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
class SettingsActivity : ComponentActivity() {
|
class SettingsActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -78,12 +88,89 @@ fun onOpenFolder(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
fun UpdateIndicator(
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
updateStatus: ReleaseViewModel.ReleaseUIState
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var enablePopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
when (updateStatus) {
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||||
|
enablePopup = true
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||||
|
// is this the ideal design? idk
|
||||||
|
enablePopup = true
|
||||||
|
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||||
|
|
||||||
|
CircularProgressIndicator(progress.toFloat())
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(updateStatus) {
|
||||||
|
// only show popup if some progress was shown
|
||||||
|
if (!enablePopup) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
when (updateStatus) {
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||||
|
val message = when (updateStatus.exception) {
|
||||||
|
is UnknownHostException, is ConnectException ->
|
||||||
|
context.getString(R.string.release_fetch_no_internet)
|
||||||
|
else -> context.getString(R.string.preference_check_for_updates_failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||||
|
if (updateStatus.hasUpdated) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.getString(R.string.preference_check_for_updates_success)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.getString(R.string.preference_check_for_updates_none_found)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
|
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var showUpdateInProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showUpdateInProgress) {
|
||||||
|
LaunchedEffect(snackbarHostState) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
context.getString(R.string.preference_check_for_updates_already_updating)
|
||||||
|
)
|
||||||
|
showUpdateInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(hostState = snackbarHostState)
|
||||||
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -107,18 +194,19 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
OptionsGroup(context.getString(R.string.preference_category_testing)) {
|
OptionsGroup(context.getString(R.string.preference_category_testing)) {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = context.getString(R.string.preference_load_testing_name),
|
title = context.getString(R.string.preference_load_testing_name),
|
||||||
preferenceId = R.string.preference_load_testing
|
preferenceKey = PreferenceUtils.Key.LOAD_TESTING,
|
||||||
)
|
)
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = context.getString(R.string.preference_load_automatically_name),
|
title = context.getString(R.string.preference_load_automatically_name),
|
||||||
description = context.getString(R.string.preference_load_automatically_description),
|
description = context.getString(R.string.preference_load_automatically_description),
|
||||||
preferenceId = R.string.preference_load_automatically
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||||
)
|
)
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
title = context.getString(R.string.preferences_copy_external_button),
|
title = context.getString(R.string.preferences_copy_external_button),
|
||||||
@@ -126,6 +214,41 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
onClick = { onOpenFolder(context) }
|
onClick = { onOpenFolder(context) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = context.getString(R.string.preference_update_automatically_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = context.getString(R.string.preference_release_channel_name),
|
||||||
|
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
|
||||||
|
)
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = stringResource(R.string.preference_check_for_updates_button),
|
||||||
|
description = stringResource(
|
||||||
|
R.string.preference_check_for_updates_description,
|
||||||
|
currentRelease ?: "unknown"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
if (releaseViewModel.isInUpdate) {
|
||||||
|
showUpdateInProgress = true
|
||||||
|
} else {
|
||||||
|
releaseViewModel.runReleaseCheck()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
UpdateIndicator(snackbarHostState, updateStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
OptionsGroup("Data") {
|
OptionsGroup("Data") {
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
@@ -139,28 +262,16 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSetting(context: Context, @StringRes preferenceId: Int): Boolean {
|
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
val preferences = context.getSharedPreferences(
|
val preferences = PreferenceUtils.get(context)
|
||||||
context.getString(R.string.preference_file_key), Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
|
|
||||||
val current = preferences.getBoolean(
|
return preferences.toggleBoolean(preferenceKey)
|
||||||
context.getString(preferenceId), false
|
|
||||||
)
|
|
||||||
preferences.edit {
|
|
||||||
putBoolean(context.getString(preferenceId), !current)
|
|
||||||
commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
return preferences.getBoolean(context.getString(preferenceId), false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSetting(context: Context, @StringRes preferenceId: Int): Boolean {
|
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
val preferences = context.getSharedPreferences(
|
val preferences = PreferenceUtils.get(context)
|
||||||
context.getString(R.string.preference_file_key), Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
|
|
||||||
return preferences.getBoolean(context.getString(preferenceId), false)
|
return preferences.getBoolean(preferenceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -190,10 +301,11 @@ fun OptionsButton(title: String, description: String? = null, onClick: () -> Uni
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsCard(title: String, description: String? = null, @StringRes preferenceId: Int) {
|
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settingEnabled = remember {
|
val settingEnabled = remember {
|
||||||
mutableStateOf(getSetting(context, preferenceId)) }
|
mutableStateOf(getSetting(context, preferenceKey))
|
||||||
|
}
|
||||||
|
|
||||||
OptionsCard(
|
OptionsCard(
|
||||||
title = {
|
title = {
|
||||||
@@ -205,7 +317,7 @@ fun SettingsCard(title: String, description: String? = null, @StringRes preferen
|
|||||||
},
|
},
|
||||||
modifier = Modifier.toggleable(
|
modifier = Modifier.toggleable(
|
||||||
value = settingEnabled.value,
|
value = settingEnabled.value,
|
||||||
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceId) },
|
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||||
role = Role.Switch,
|
role = Role.Switch,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -253,11 +365,11 @@ fun OptionsCardPreview() {
|
|||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = "Load files from /test",
|
title = "Load files from /test",
|
||||||
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
||||||
preferenceId = R.string.preference_load_testing
|
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
|
||||||
)
|
)
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = "Testing option 2",
|
title = "Testing option 2",
|
||||||
preferenceId = R.string.preference_load_automatically
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.geode.launcher.api
|
||||||
|
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Asset(
|
||||||
|
val url: String,
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val size: Int,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val browserDownloadUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Release(
|
||||||
|
val url: String,
|
||||||
|
val id: Int,
|
||||||
|
val targetCommitish: String,
|
||||||
|
val tagName: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val publishedAt: Instant,
|
||||||
|
val assets: List<Asset>
|
||||||
|
) {
|
||||||
|
fun getDescription(): String {
|
||||||
|
if (tagName == "nightly") {
|
||||||
|
// get the commit from the assets
|
||||||
|
// otherwise, a separate request is needed to get the hash (ew)
|
||||||
|
val asset = assets.first()
|
||||||
|
val commit = asset.name.substring(6..12)
|
||||||
|
|
||||||
|
return "nightly-$commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagName
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDescriptor(): Long {
|
||||||
|
return createdAt.epochSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAndroidDownload(): Asset? {
|
||||||
|
// try to find an asset that matches the architecture first
|
||||||
|
val architecture = LaunchUtils.getApplicationArchitecture()
|
||||||
|
val platform = if (architecture == "arm64-v8a")
|
||||||
|
"android64" else "android32"
|
||||||
|
|
||||||
|
val releaseSuffix = "$platform.zip"
|
||||||
|
return assets.find {
|
||||||
|
it.name.endsWith(releaseSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.geode.launcher.api
|
||||||
|
|
||||||
|
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||||
|
companion object {
|
||||||
|
private const val GITHUB_API_BASE = "https://api.github.com/repos/geode-sdk/geode"
|
||||||
|
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
|
||||||
|
private const val GITHUB_API_VERSION = "2022-11-28"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestNightlyRelease(): Release? {
|
||||||
|
val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly"
|
||||||
|
val url = URL(nightlyPath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestRelease(): Release? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/releases/latest"
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private suspend fun getReleaseByUrl(url: URL): Release? {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
val response = call.executeCoroutine()
|
||||||
|
|
||||||
|
return when (response.code) {
|
||||||
|
200 -> {
|
||||||
|
val format = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val release = format.decodeFromBufferedSource<Release>(
|
||||||
|
response.body!!.source()
|
||||||
|
)
|
||||||
|
|
||||||
|
release
|
||||||
|
}
|
||||||
|
404 -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw IOException("unknown response ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.geode.launcher.api
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.ReleaseManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[APPLICATION_KEY] as Application
|
||||||
|
|
||||||
|
ReleaseViewModel(
|
||||||
|
application = application
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ReleaseUIState {
|
||||||
|
data object InUpdateCheck : ReleaseUIState()
|
||||||
|
data class Failure(val exception: Exception) : ReleaseUIState()
|
||||||
|
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
|
||||||
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
||||||
|
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun managerStateToUI(state: ReleaseManager.ReleaseManagerState): ReleaseUIState {
|
||||||
|
return when (state) {
|
||||||
|
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> InUpdateCheck
|
||||||
|
is ReleaseManager.ReleaseManagerState.Failure -> Failure(state.exception)
|
||||||
|
is ReleaseManager.ReleaseManagerState.InDownload ->
|
||||||
|
InDownload(state.downloaded, state.outOf)
|
||||||
|
is ReleaseManager.ReleaseManagerState.Finished -> Finished(state.hasUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<ReleaseUIState>(ReleaseUIState.Finished())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val isInUpdate
|
||||||
|
get() = ReleaseManager.get(application).isInUpdate
|
||||||
|
|
||||||
|
var hasPerformedCheck = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun cancelUpdate() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = ReleaseUIState.Cancelled(true)
|
||||||
|
|
||||||
|
ReleaseManager.get(application).cancelUpdate()
|
||||||
|
|
||||||
|
_uiState.value = ReleaseUIState.Cancelled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncUiState(
|
||||||
|
flow: StateFlow<ReleaseManager.ReleaseManagerState>
|
||||||
|
) {
|
||||||
|
flow.map(ReleaseUIState::managerStateToUI).collect {
|
||||||
|
// send the mapped state to the ui
|
||||||
|
_uiState.value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun useGlobalCheckState() {
|
||||||
|
hasPerformedCheck = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
|
.uiState
|
||||||
|
|
||||||
|
syncUiState(releaseFlow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runReleaseCheck() {
|
||||||
|
hasPerformedCheck = true
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
|
.checkForUpdates()
|
||||||
|
|
||||||
|
syncUiState(releaseFlow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,4 @@ object Constants {
|
|||||||
// this value is hardcoded into GD
|
// this value is hardcoded into GD
|
||||||
@SuppressLint("SdCardPath")
|
@SuppressLint("SdCardPath")
|
||||||
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
||||||
|
|
||||||
const val GEODE_DOWNLOAD_LINK = "https://nightly.link/geode-sdk/geode/workflows/build/1.4.0-dev/geode-android.zip"
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.FileUtils
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okio.Buffer
|
||||||
|
import okio.BufferedSource
|
||||||
|
import okio.ForwardingSource
|
||||||
|
import okio.Source
|
||||||
|
import okio.buffer
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
|
||||||
|
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||||
|
|
||||||
|
object DownloadUtils {
|
||||||
|
suspend fun downloadStream(
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
url: String,
|
||||||
|
onProgress: ProgressCallback? = null
|
||||||
|
): InputStream {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// build a new client using the same pool as the old client
|
||||||
|
// (more efficient)
|
||||||
|
val progressClientBuilder = httpClient.newBuilder()
|
||||||
|
.cache(null)
|
||||||
|
// disable timeout
|
||||||
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
// add progress listener
|
||||||
|
if (onProgress != null) {
|
||||||
|
progressClientBuilder.addNetworkInterceptor { chain ->
|
||||||
|
val originalResponse = chain.proceed(chain.request())
|
||||||
|
originalResponse.newBuilder()
|
||||||
|
.body(ProgressResponseBody(
|
||||||
|
originalResponse.body!!, onProgress
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressClient = progressClientBuilder.build()
|
||||||
|
|
||||||
|
val call = progressClient.newCall(request)
|
||||||
|
val response = call.executeCoroutine()
|
||||||
|
|
||||||
|
return response.body!!.byteStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun Call.executeCoroutine(): Response {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
this.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isCancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
continuation.resume(response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
|
||||||
|
// note to self: ZipInputStreams are a little silly
|
||||||
|
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
||||||
|
runInterruptible {
|
||||||
|
inputStream.use {
|
||||||
|
outputStream.use {
|
||||||
|
// nice indentation
|
||||||
|
val zip = ZipInputStream(inputStream)
|
||||||
|
zip.use {
|
||||||
|
var entry = it.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (entry.name == zipPath) {
|
||||||
|
it.copyTo(outputStream)
|
||||||
|
return@use
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = it.nextEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// no matching entries found, throw exception
|
||||||
|
throw IOException("no file found for $zipPath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||||
|
// gotta love copying
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
FileUtils.copy(inputStream, outputStream)
|
||||||
|
} else {
|
||||||
|
inputStream.use { input ->
|
||||||
|
outputStream.use { output ->
|
||||||
|
val buffer = ByteArray(4 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val byteCount = input.read(buffer)
|
||||||
|
if (byteCount < 0) break
|
||||||
|
output.write(buffer, 0, byteCount)
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
|
||||||
|
// lazily ported to kotlin
|
||||||
|
private class ProgressResponseBody (
|
||||||
|
private val responseBody: ResponseBody,
|
||||||
|
private val progressCallback: ProgressCallback
|
||||||
|
) : ResponseBody() {
|
||||||
|
private val bufferedSource: BufferedSource by lazy {
|
||||||
|
source(responseBody.source()).buffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun contentType(): MediaType? {
|
||||||
|
return responseBody.contentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun contentLength(): Long {
|
||||||
|
return responseBody.contentLength()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun source(): BufferedSource {
|
||||||
|
return bufferedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun source(source: Source): Source {
|
||||||
|
return object : ForwardingSource(source) {
|
||||||
|
var totalBytesRead = 0L
|
||||||
|
|
||||||
|
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||||
|
val bytesRead = super.read(sink, byteCount)
|
||||||
|
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||||
|
|
||||||
|
progressCallback(
|
||||||
|
totalBytesRead,
|
||||||
|
responseBody.contentLength()
|
||||||
|
)
|
||||||
|
|
||||||
|
return bytesRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,13 @@ object LaunchUtils {
|
|||||||
return Build.CPU_ABI
|
return Build.CPU_ABI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGeodeFilename(): String {
|
||||||
|
val abi = getApplicationArchitecture()
|
||||||
|
return "Geode.$abi.so"
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstalledGeodePath(context: Context): File? {
|
fun getInstalledGeodePath(context: Context): File? {
|
||||||
val arch = getApplicationArchitecture()
|
val geodeName = getGeodeFilename()
|
||||||
val geodeName = "Geode.$arch.so"
|
|
||||||
|
|
||||||
val internalGeodePath = File(context.filesDir.path, "launcher/$geodeName")
|
val internalGeodePath = File(context.filesDir.path, "launcher/$geodeName")
|
||||||
if (internalGeodePath.exists()) {
|
if (internalGeodePath.exists()) {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension object for SharedPreferences to add better key safety and default values.
|
||||||
|
*/
|
||||||
|
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||||
|
companion object {
|
||||||
|
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
||||||
|
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
||||||
|
p.getBoolean(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
|
||||||
|
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
||||||
|
p.getString(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState<T> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val preferences = get(sharedPreferences)
|
||||||
|
|
||||||
|
val preferenceValue = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
preferenceGet(preferences, preferenceKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
_, _ -> preferenceValue.value = preferenceGet(preferences, preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_START) {
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
} else if (event == Lifecycle.Event.ON_DESTROY) {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferenceValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(context: Context): PreferenceUtils {
|
||||||
|
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
||||||
|
return get(sharedPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(sharedPreferences: SharedPreferences): PreferenceUtils {
|
||||||
|
return PreferenceUtils(sharedPreferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Key {
|
||||||
|
LOAD_TESTING,
|
||||||
|
LOAD_AUTOMATICALLY,
|
||||||
|
UPDATE_AUTOMATICALLY,
|
||||||
|
RELEASE_CHANNEL,
|
||||||
|
CURRENT_VERSION_TAG,
|
||||||
|
CURRENT_VERSION_TIMESTAMP
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
|
return when (key) {
|
||||||
|
Key.UPDATE_AUTOMATICALLY, Key.RELEASE_CHANNEL -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyToName(key: Key): String {
|
||||||
|
return when (key) {
|
||||||
|
Key.LOAD_TESTING -> "PreferenceLoadTesting"
|
||||||
|
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||||
|
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||||
|
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
|
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
|
||||||
|
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBoolean(key: Key): Boolean {
|
||||||
|
val defaultValue = defaultValueForBooleanKey(key)
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
|
||||||
|
return sharedPreferences.getBoolean(keyName, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleBoolean(key: Key): Boolean {
|
||||||
|
val currentValue = getBoolean(key)
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putBoolean(keyName, !currentValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return !currentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getString(key: Key): String? {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
return sharedPreferences.getString(keyName, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setString(key: Key, value: String) {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString(keyName, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLong(key: Key): Long {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
return sharedPreferences.getLong(keyName, 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLong(key: Key, value: Long) {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putLong(keyName, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.geode.launcher.api.Release
|
||||||
|
import com.geode.launcher.api.ReleaseRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton to manage Geode updates, from update checking to downloading.
|
||||||
|
*/
|
||||||
|
class ReleaseManager private constructor(
|
||||||
|
private val applicationContext: Context,
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val releaseRepository: ReleaseRepository = ReleaseRepository(httpClient)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private lateinit var managerInstance: ReleaseManager
|
||||||
|
|
||||||
|
fun get(context: Context): ReleaseManager {
|
||||||
|
if (!::managerInstance.isInitialized) {
|
||||||
|
val applicationContext = context.applicationContext
|
||||||
|
|
||||||
|
val httpClient = OkHttpClient.Builder()
|
||||||
|
.cache(Cache(
|
||||||
|
applicationContext.cacheDir,
|
||||||
|
maxSize = 10L * 1024L * 1024L // 10mb cache size
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
managerInstance = ReleaseManager(
|
||||||
|
applicationContext,
|
||||||
|
httpClient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return managerInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ReleaseManagerState {
|
||||||
|
data object InUpdateCheck : ReleaseManagerState()
|
||||||
|
data class Failure(val exception: Exception) : ReleaseManagerState()
|
||||||
|
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
|
||||||
|
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var updateJob: Job? = null
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<ReleaseManagerState>(ReleaseManagerState.Finished())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val isInUpdate: Boolean
|
||||||
|
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||||
|
|
||||||
|
private fun sendError(e: Exception) {
|
||||||
|
_uiState.value = ReleaseManagerState.Failure(e)
|
||||||
|
|
||||||
|
// ignore cancellation, it's good actually
|
||||||
|
if (e !is CancellationException && e !is InterruptedIOException) {
|
||||||
|
Log.w("Geode", "Release download has failed:")
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestRelease(): Release? {
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
|
||||||
|
|
||||||
|
val latestRelease = if (useNightly) {
|
||||||
|
releaseRepository.getLatestNightlyRelease()
|
||||||
|
} else {
|
||||||
|
releaseRepository.getLatestRelease()
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performUpdate(release: Release) {
|
||||||
|
val releaseAsset = release.getAndroidDownload()
|
||||||
|
if (releaseAsset == null) {
|
||||||
|
val noAssetException = Exception("missing Android download")
|
||||||
|
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// set an initial download size
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
||||||
|
|
||||||
|
try {
|
||||||
|
val fileStream = DownloadUtils.downloadStream(httpClient, releaseAsset.browserDownloadUrl) { progress, outOf ->
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
val geodeName = LaunchUtils.getGeodeFilename()
|
||||||
|
val geodeFile = getGeodeOutputPath(geodeName)
|
||||||
|
|
||||||
|
// work around a permission issue from adb push
|
||||||
|
if (geodeFile.exists()) {
|
||||||
|
geodeFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadUtils.extractFileFromZipStream(
|
||||||
|
fileStream,
|
||||||
|
geodeFile.outputStream(),
|
||||||
|
geodeName
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraction performed
|
||||||
|
updatePreferences(release)
|
||||||
|
_uiState.value = ReleaseManagerState.Finished(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkForNewRelease() {
|
||||||
|
val release = try {
|
||||||
|
getLatestRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release == null) {
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
||||||
|
val latestVersion = release.getDescriptor()
|
||||||
|
|
||||||
|
// check if an update is needed
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
performUpdate(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updatePreferences(release: Release) {
|
||||||
|
coroutineScope {
|
||||||
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
|
sharedPreferences.setString(
|
||||||
|
PreferenceUtils.Key.CURRENT_VERSION_TAG,
|
||||||
|
release.getDescription()
|
||||||
|
)
|
||||||
|
|
||||||
|
sharedPreferences.setLong(
|
||||||
|
PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP,
|
||||||
|
release.getDescriptor()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGeodeOutputPath(geodeName: String): File {
|
||||||
|
val fallbackPath = File(applicationContext.filesDir, "launcher")
|
||||||
|
val geodeDirectory = applicationContext.getExternalFilesDir("") ?: fallbackPath
|
||||||
|
|
||||||
|
return File(geodeDirectory, geodeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the current update job.
|
||||||
|
*/
|
||||||
|
suspend fun cancelUpdate() {
|
||||||
|
updateJob?.cancelAndJoin()
|
||||||
|
updateJob = null
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a new update checking job.
|
||||||
|
* @return Flow that tracks the state of the update.
|
||||||
|
*/
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
fun checkForUpdates(): StateFlow<ReleaseManagerState> {
|
||||||
|
if (!isInUpdate) {
|
||||||
|
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||||
|
updateJob = GlobalScope.launch {
|
||||||
|
checkForNewRelease()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _uiState.asStateFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,53 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import android.os.CountDownTimer
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
const val MS_TO_SEC = 1000
|
const val MS_TO_SEC = 1000L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useCountdownTimer(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, time: Int, onCountdownFinish: () -> Unit): MutableState<Int> {
|
fun useCountdownTimer(
|
||||||
val timeData = remember {
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||||
mutableStateOf(time / MS_TO_SEC % MS_TO_SEC)
|
time: Long,
|
||||||
|
onCountdownFinish: () -> Unit
|
||||||
|
): Long {
|
||||||
|
var millisUntilFinished by remember {
|
||||||
|
mutableLongStateOf(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
val countdownTimer =
|
var shouldBeCounting by remember {
|
||||||
object : CountDownTimer(time.toLong(), 1000) {
|
mutableStateOf(true)
|
||||||
override fun onTick(millisUntilFinished: Long) {
|
}
|
||||||
timeData.value = (millisUntilFinished / MS_TO_SEC % MS_TO_SEC).toInt() + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFinish() {
|
LaunchedEffect(shouldBeCounting, millisUntilFinished) {
|
||||||
onCountdownFinish()
|
if (!shouldBeCounting) {
|
||||||
}
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (millisUntilFinished > 0) {
|
||||||
|
delay(MS_TO_SEC)
|
||||||
|
millisUntilFinished -= MS_TO_SEC
|
||||||
|
} else {
|
||||||
|
onCountdownFinish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_START) {
|
when (event) {
|
||||||
countdownTimer.start()
|
Lifecycle.Event.ON_START -> {
|
||||||
} else if (event == Lifecycle.Event.ON_STOP) {
|
shouldBeCounting = true
|
||||||
countdownTimer.cancel()
|
millisUntilFinished = time
|
||||||
|
}
|
||||||
|
Lifecycle.Event.ON_PAUSE,
|
||||||
|
Lifecycle.Event.ON_STOP -> {
|
||||||
|
shouldBeCounting = false
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,5 +58,5 @@ fun useCountdownTimer(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.curre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeData
|
return millisUntilFinished / MS_TO_SEC
|
||||||
}
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.geode.launcher.utils
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun usePreference(@StringRes preferenceFileKey: Int, @StringRes preferenceId: Int, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val preferences = context.getSharedPreferences(
|
|
||||||
context.getString(preferenceFileKey), Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
|
|
||||||
val preferenceValue = remember {
|
|
||||||
mutableStateOf(
|
|
||||||
preferences.getBoolean(
|
|
||||||
context.getString(preferenceId),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
sharedPreferences, key -> preferenceValue.value = sharedPreferences.getBoolean(key, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
if (event == Lifecycle.Event.ON_START) {
|
|
||||||
preferences.registerOnSharedPreferenceChangeListener(listener)
|
|
||||||
} else if (event == Lifecycle.Event.ON_DESTROY) {
|
|
||||||
preferences.unregisterOnSharedPreferenceChangeListener(listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return preferenceValue
|
|
||||||
}
|
|
||||||
@@ -17,13 +17,20 @@
|
|||||||
<item quantity="other">Automatically launching game in %1$d seconds.</item>
|
<item quantity="other">Automatically launching game in %1$d seconds.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
<!-- GeometryDashActivity strings -->
|
<!-- Updater strings -->
|
||||||
<string name="message_box_accept">Ok</string>
|
<string name="release_fetch_failed">Update failed.\n%1$s</string>
|
||||||
|
<string name="release_fetch_no_releases">No updates found.</string>
|
||||||
|
<string name="release_fetch_in_progress">Checking for new releases…</string>
|
||||||
|
<string name="release_fetch_success">Geode updated!</string>
|
||||||
|
<string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string>
|
||||||
|
<string name="release_fetch_cancelling">Cancelling update…</string>
|
||||||
|
<string name="release_fetch_no_internet">No internet found.</string>
|
||||||
|
<string name="release_fetch_cancelled">Update cancelled.</string>
|
||||||
|
<string name="release_fetch_button_retry">Retry</string>
|
||||||
|
<string name="release_fetch_button_cancel">Cancel</string>
|
||||||
|
|
||||||
<!-- Preference keys -->
|
<!-- GeometryDashActivity strings -->
|
||||||
<string name="preference_file_key">GeodeLauncherPreferencesFileKey</string>
|
<string name="message_box_accept">OK</string>
|
||||||
<string name="preference_load_testing">PreferenceLoadTesting</string>
|
|
||||||
<string name="preference_load_automatically">PreferenceLoadAutomatically</string>
|
|
||||||
|
|
||||||
<!-- SettingsActivity strings -->
|
<!-- SettingsActivity strings -->
|
||||||
<string name="title_activity_settings">Settings</string>
|
<string name="title_activity_settings">Settings</string>
|
||||||
@@ -37,4 +44,17 @@
|
|||||||
<string name="preference_load_automatically_name">Automatically launch</string>
|
<string name="preference_load_automatically_name">Automatically launch</string>
|
||||||
<string name="preference_load_automatically_description">Launches the game after a short delay.</string>
|
<string name="preference_load_automatically_description">Launches the game after a short delay.</string>
|
||||||
<string name="preferences_copy_external_button">Copy external files directory</string>
|
<string name="preferences_copy_external_button">Copy external files directory</string>
|
||||||
|
|
||||||
|
<string name="preference_category_updater">Updates</string>
|
||||||
|
<string name="preference_update_automatically_name">Enable automatic updates</string>
|
||||||
|
<string name="preference_release_channel_name">Use nightly release channel</string>
|
||||||
|
|
||||||
|
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||||
|
<string name="preference_check_for_updates_description">Current version: %1$s</string>
|
||||||
|
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
||||||
|
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
||||||
|
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||||
|
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
||||||
|
<string name="preference_check_for_updates_no_internet">No internet found.</string>
|
||||||
|
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -8,6 +8,7 @@ plugins {
|
|||||||
id 'com.android.application' version '8.2.0' apply false
|
id 'com.android.application' version '8.2.0' apply false
|
||||||
id 'com.android.library' version '8.2.0' apply false
|
id 'com.android.library' version '8.2.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '1.9.21' apply false
|
id 'org.jetbrains.kotlin.android' version '1.9.21' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('clean', Delete) {
|
tasks.register('clean', Delete) {
|
||||||
|
|||||||
Reference in new issue
Block a user