Skip to content

Commit 3857255

Browse files
feat: backup & restore functionality (#40)
* chore: bump bootstrap to v0.1.4 * ui(SettingsActivity.kt): add backup & restore options * feat: backup & restore functionality * feat: improvements like confrimation dialog, etc * fix: edge case in ui terminal logging sync
1 parent deaf7cc commit 3857255

7 files changed

Lines changed: 260 additions & 12 deletions

File tree

app/build.gradle.kts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ plugins {
2525
alias(libs.plugins.jetbrainsKotlinAndroid)
2626
}
2727

28-
val bootstrapVersion = "v0.1.3"
28+
val bootstrapVersion = "v0.1.4"
2929

3030
android {
3131
namespace = "xyz.jekyllex"
@@ -166,7 +166,7 @@ fun getGitHash(): String {
166166
return "\"" + stdout.toString().trim() + "\""
167167
}
168168

169-
fun downloadBootstrap(arch: String, expectedChecksum: String, version: String) {
169+
fun downloadBootstrap(arch: String, expectedChecksum: String) {
170170
val buffer = ByteArray(8192)
171171
val digest = MessageDigest.getInstance("SHA-256")
172172
val zipDownloadFile = File(project.rootDir, "bootstraps/ruby-${arch}.zip")
@@ -182,12 +182,13 @@ fun downloadBootstrap(arch: String, expectedChecksum: String, version: String) {
182182
val checksum = BigInteger(1, digest.digest()).toString(16)
183183
if (checksum != expectedChecksum) {
184184
logger.quiet("Deleting old local file with wrong hash: ${zipDownloadFile.absolutePath}")
185+
File("${zipDownloadFile.absolutePath}.done").delete()
185186
zipDownloadFile.delete()
186187
}
187188
}
188189

189190
if (!zipDownloadFile.exists()) {
190-
val remoteUrl = "https://dl.jekyllex.xyz/ruby/$version/$arch.zip"
191+
val remoteUrl = "https://dl.jekyllex.xyz/ruby/$bootstrapVersion/$arch.zip"
191192
logger.quiet("Downloading $remoteUrl ...")
192193

193194
zipDownloadFile.parentFile.mkdirs()
@@ -317,13 +318,13 @@ tasks {
317318
val downloadBootstraps by registering {
318319
doFirst {
319320
val map = mapOf(
320-
"aarch64" to "266b081bb64e33541808e2f627e4667ed8f8ef10a0edbfe736c3338c97930e9b",
321-
"arm" to "57f7c270d6203323af3d30f626b1c41a1d59d0e6a6cb0b57a5e908c7a6349c35",
322-
"i686" to "8353c79ca752d754f00da4cd33b6245c253d79a852c8066a1e0809684d178539",
323-
"x86_64" to "46556fa1b3b690d0c105f7c110f2dd5d57d9a3ab0c29eab2fa3a963d2db41aea"
321+
"aarch64" to "6dfa705dcff38f0ade4f5ac202c49a14b863d25fbf994f71ef21a9ad7eb2a9ce",
322+
"arm" to "8874edb85cb3a9d7ee49c6670fa10e30340eddb02a551769c78349697d4cc962",
323+
"i686" to "c4531c473b084ccef0f367cb19dd633636aacf55043f5031d6b0e3c0814dbcfe",
324+
"x86_64" to "d1f28ff6a08c128974a6d777af06c4603a07c129d877d608072c4596bef6cee8"
324325
)
325326

326-
map.forEach { (arch, checksum) -> downloadBootstrap(arch, checksum, bootstrapVersion) }
327+
map.forEach { (arch, checksum) -> downloadBootstrap(arch, checksum) }
327328
}
328329
}
329330
}

app/src/main/java/xyz/jekyllex/services/ProcessService.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,10 @@ class ProcessService : Service() {
154154
fun cd(dir: String) {
155155
val defaultSession = _sessions.value.first()
156156
val currentDir = defaultSession.dir.value
157-
defaultSession.cd(dir)
158-
if (isRunning) return
159-
defaultSession.appendLog(
157+
if (!isRunning) defaultSession.appendLog(
160158
"${currentDir.formatDir("/")} $ cd ${dir.formatDir("/")}"
161159
)
160+
defaultSession.cd(dir)
162161
}
163162

164163
fun exec(cmd: Array<String>, dir: String? = null, callBack: () -> Unit = {}) {

app/src/main/java/xyz/jekyllex/ui/activities/settings/SettingsActivity.kt

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import android.net.Uri
3030
import android.os.Bundle
3131
import android.widget.Toast
3232
import androidx.activity.ComponentActivity
33+
import androidx.activity.compose.rememberLauncherForActivityResult
3334
import androidx.activity.compose.setContent
35+
import androidx.activity.result.contract.ActivityResultContracts
3436
import androidx.compose.foundation.clickable
3537
import androidx.compose.foundation.layout.PaddingValues
3638
import androidx.compose.foundation.layout.padding
@@ -45,6 +47,9 @@ import androidx.compose.material3.MaterialTheme
4547
import androidx.compose.material3.Scaffold
4648
import androidx.compose.material3.Text
4749
import androidx.compose.runtime.Composable
50+
import androidx.compose.runtime.mutableStateOf
51+
import androidx.compose.runtime.remember
52+
import androidx.compose.runtime.rememberCoroutineScope
4853
import androidx.compose.ui.Modifier
4954
import androidx.compose.ui.platform.LocalClipboardManager
5055
import androidx.compose.ui.platform.LocalContext
@@ -54,6 +59,10 @@ import androidx.compose.ui.text.SpanStyle
5459
import androidx.compose.ui.text.buildAnnotatedString
5560
import androidx.compose.ui.text.withStyle
5661
import androidx.compose.ui.unit.dp
62+
import kotlinx.coroutines.CoroutineScope
63+
import kotlinx.coroutines.Dispatchers
64+
import kotlinx.coroutines.launch
65+
import kotlinx.coroutines.withContext
5766
import me.zhanghai.compose.preference.ProvidePreferenceLocals
5867
import me.zhanghai.compose.preference.footerPreference
5968
import me.zhanghai.compose.preference.listPreference
@@ -72,6 +81,13 @@ import xyz.jekyllex.utils.Setting.*
7281
import xyz.jekyllex.utils.trimQuotes
7382
import xyz.jekyllex.BuildConfig
7483
import xyz.jekyllex.ui.activities.viewer.WebPageViewer
84+
import xyz.jekyllex.ui.components.GenericDialog
85+
import xyz.jekyllex.ui.components.ProgressDialog
86+
import xyz.jekyllex.utils.Commands.rm
87+
import xyz.jekyllex.utils.Commands.rmDir
88+
import xyz.jekyllex.utils.Commands.shell
89+
import xyz.jekyllex.utils.Commands.unzip
90+
import xyz.jekyllex.utils.Commands.zip
7591
import xyz.jekyllex.utils.Constants.DOCS
7692
import xyz.jekyllex.utils.Constants.TERMS
7793
import xyz.jekyllex.utils.Constants.PRIVACY
@@ -80,8 +96,15 @@ import xyz.jekyllex.utils.Constants.themeMap
8096
import xyz.jekyllex.utils.Constants.ISSUES_URL
8197
import xyz.jekyllex.utils.Constants.PAT_SETTINGS_URL
8298
import xyz.jekyllex.utils.Constants.EDITOR_PREVIEWS_URL
99+
import xyz.jekyllex.utils.Constants.HOME_DIR
100+
import xyz.jekyllex.utils.Constants.TMP_DIR
101+
import xyz.jekyllex.utils.mergeCommands
102+
import java.io.File
103+
import java.io.FileInputStream
104+
import java.io.FileOutputStream
83105

84106
class SettingsActivity : ComponentActivity() {
107+
85108
override fun onCreate(savedInstanceState: Bundle?) {
86109
super.onCreate(savedInstanceState)
87110

@@ -95,8 +118,107 @@ class SettingsActivity : ComponentActivity() {
95118

96119
@Composable
97120
fun SettingsView() {
121+
val coroutineScope = rememberCoroutineScope()
98122
val context = LocalContext.current as Activity
99123
val clipboardManager = LocalClipboardManager.current
124+
val processing = remember { mutableStateOf(false) }
125+
val restoreConfirmation = remember { mutableStateOf<Uri?>(null) }
126+
127+
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
128+
uri?.let {
129+
restoreConfirmation.value = uri
130+
} ?: run {
131+
Toast.makeText(context, "No file selected!", Toast.LENGTH_SHORT)
132+
.show()
133+
}
134+
}
135+
136+
val saver = rememberLauncherForActivityResult(
137+
contract = ActivityResultContracts.StartActivityForResult()
138+
) { result ->
139+
val uri = result.data?.data
140+
val file = File(HOME_DIR, "backup.zip")
141+
uri?.let {
142+
coroutineScope.launch(Dispatchers.IO) {
143+
try {
144+
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
145+
FileInputStream(file).use { inputStream ->
146+
inputStream.copyTo(outputStream)
147+
}
148+
}
149+
withContext(Dispatchers.Main) {
150+
Toast.makeText(context, "Backup saved!", Toast.LENGTH_SHORT).show()
151+
}
152+
} catch (e: Exception) {
153+
e.printStackTrace()
154+
withContext(Dispatchers.Main) {
155+
Toast.makeText(context, "Failed to save backup file!", Toast.LENGTH_SHORT)
156+
.show()
157+
}
158+
}
159+
file.delete()
160+
}
161+
} ?: run {
162+
file.delete()
163+
Toast.makeText(context, "No location selected!", Toast.LENGTH_SHORT)
164+
.show()
165+
}
166+
}
167+
168+
if (processing.value) {
169+
ProgressDialog(dialogTitle = "Processing...")
170+
}
171+
172+
if (restoreConfirmation.value != null) {
173+
GenericDialog(
174+
isCancellable = false,
175+
dialogTitle = "Restore data",
176+
dialogText = "Are you sure you want to restore data from the selected file?\n\n" +
177+
"This will overwrite all existing data.",
178+
onDismissRequest = {
179+
restoreConfirmation.value = null
180+
},
181+
onConfirmation = confirmation@{
182+
val uri = restoreConfirmation.value ?: return@confirmation
183+
val file = File(TMP_DIR, "backup-${System.currentTimeMillis()}.zip")
184+
restoreConfirmation.value = null
185+
coroutineScope.launch(Dispatchers.IO) {
186+
processing.value = true
187+
try {
188+
context.contentResolver.openInputStream(uri)?.use { inputStream ->
189+
FileOutputStream(file).use { outputStream ->
190+
inputStream.copyTo(outputStream)
191+
}
192+
}
193+
NativeUtils.exec(
194+
shell(
195+
mergeCommands(
196+
rmDir("*", ".*"),
197+
unzip(file.absolutePath, "-d", "$HOME_DIR/")
198+
)
199+
)
200+
)
201+
processing.value = false
202+
withContext(Dispatchers.Main) {
203+
Toast.makeText(
204+
context,
205+
"Backup restored successfully!",
206+
Toast.LENGTH_SHORT
207+
).show()
208+
}
209+
} catch (e: Exception) {
210+
e.printStackTrace()
211+
withContext(Dispatchers.Main) {
212+
processing.value = false
213+
Toast.makeText(context, "Failed to fetch backup file!", Toast.LENGTH_SHORT)
214+
.show()
215+
}
216+
}
217+
file.delete()
218+
}
219+
}
220+
)
221+
}
100222

101223
Scaffold(
102224
topBar = {
@@ -384,6 +506,47 @@ fun SettingsView() {
384506
},
385507
)
386508

509+
preferenceCategory(
510+
key = "advanced",
511+
title = { Text("Advanced") },
512+
)
513+
514+
preference(
515+
key = "backup",
516+
onClick = {
517+
processing.value = true
518+
NativeUtils.exec(
519+
shell(
520+
mergeCommands(
521+
rm("backup.zip"),
522+
zip("-r", "backup.zip", ".", "-x", "'.bundle/*'")
523+
),
524+
),
525+
CoroutineScope(Dispatchers.IO)
526+
) {
527+
processing.value = false
528+
withContext(Dispatchers.Main) {
529+
saver.launch(
530+
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
531+
type = "application/zip"
532+
addCategory(Intent.CATEGORY_OPENABLE)
533+
putExtra(Intent.EXTRA_TITLE, "backup.zip")
534+
}
535+
)
536+
}
537+
}
538+
},
539+
title = { Text(context.getString(R.string.backup_setting_title)) },
540+
summary = { Text(context.getString(R.string.backup_setting_summary)) },
541+
)
542+
543+
preference(
544+
key = "restore",
545+
onClick = { picker.launch("application/zip") },
546+
title = { Text(context.getString(R.string.restore_setting_title)) },
547+
summary = { Text(context.getString(R.string.restore_setting_summary)) },
548+
)
549+
387550
preferenceCategory(
388551
key = "other",
389552
title = { Text("Other") }
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Gourav Khunger
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package xyz.jekyllex.ui.components
26+
27+
import androidx.compose.foundation.layout.Row
28+
import androidx.compose.foundation.layout.padding
29+
import androidx.compose.foundation.layout.size
30+
import androidx.compose.foundation.layout.wrapContentHeight
31+
import androidx.compose.foundation.layout.wrapContentWidth
32+
import androidx.compose.foundation.shape.RoundedCornerShape
33+
import androidx.compose.material3.BasicAlertDialog
34+
import androidx.compose.material3.CircularProgressIndicator
35+
import androidx.compose.material3.MaterialTheme
36+
import androidx.compose.material3.Surface
37+
import androidx.compose.material3.Text
38+
import androidx.compose.runtime.Composable
39+
import androidx.compose.ui.Alignment
40+
import androidx.compose.ui.Modifier
41+
import androidx.compose.ui.unit.dp
42+
43+
@Composable
44+
fun ProgressDialog(
45+
dialogTitle: String,
46+
isCancellable: Boolean = false,
47+
onDismissRequest: () -> Unit = {},
48+
) {
49+
BasicAlertDialog (
50+
onDismissRequest = {
51+
if (isCancellable) onDismissRequest()
52+
},
53+
) {
54+
Surface(
55+
shape = RoundedCornerShape(8.dp),
56+
modifier = Modifier.wrapContentWidth().wrapContentHeight(),
57+
) {
58+
Row(
59+
modifier = Modifier.padding(20.dp),
60+
verticalAlignment = Alignment.CenterVertically
61+
) {
62+
CircularProgressIndicator(
63+
strokeWidth = 2.dp,
64+
modifier = Modifier.size(24.dp)
65+
)
66+
Text(
67+
text = dialogTitle,
68+
modifier = Modifier.padding(start = 16.dp),
69+
style = MaterialTheme.typography.titleMedium,
70+
)
71+
}
72+
}
73+
}
74+
}

app/src/main/java/xyz/jekyllex/utils/Commands.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ package xyz.jekyllex.utils
2727
object Commands {
2828
fun cat(vararg file: String): Array<String> = arrayOf("cat", *file)
2929
fun rm(vararg files: String): Array<String> = arrayOf("rm", *files)
30+
fun zip(vararg command: String): Array<String> = arrayOf("zip", *command)
3031
fun rmDir(vararg dirs: String): Array<String> = arrayOf("rm", "-rf", *dirs)
3132
fun mv(src: String, dest: String): Array<String> = arrayOf("mv", src, dest)
3233
fun stat(vararg command: String): Array<String> = arrayOf("stat", *command)
3334
fun echo(vararg command: String): Array<String> = arrayOf("echo", *command)
3435
fun touch(vararg command: String): Array<String> = arrayOf("touch", *command)
3536
fun mkDir(vararg command: String): Array<String> = arrayOf("mkdir", *command)
3637
fun diskUsage(vararg command: String): Array<String> = arrayOf("du", *command)
38+
fun unzip(vararg command: String): Array<String> = arrayOf("unzip", *command)
3739
fun shell(vararg command: String): Array<String> = arrayOf("/bin/sh", "-c", *command)
3840

3941
fun git(vararg command: String): Array<String> = arrayOf("git", *command)

app/src/main/java/xyz/jekyllex/utils/Utils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ import xyz.jekyllex.utils.Constants.editorExtensions
4242
import xyz.jekyllex.ui.activities.editor.EditorActivity
4343
import xyz.jekyllex.utils.Constants.COMMAND_NOT_ALLOWED
4444

45-
fun Array<String>.isDenied(): Boolean = Constants.denyList.any { this.getOrNull(0) == it }
4645
fun Array<String>.drop(n: Int): Array<String> = this.toList().drop(n).toTypedArray()
46+
fun Array<String>.isDenied(): Boolean = Constants.denyList.any { this.getOrNull(0) == it }
4747

4848
fun Array<String>.transform(context: Context): Array<String> = this.let {
4949
val settings = Settings(context)

app/src/main/res/values/strings.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@
130130

131131
<string name="theme_setting_title">Color theme</string>
132132

133+
<string name="backup_setting_title">Backup</string>
134+
<string name="backup_setting_summary">
135+
Backup all projects to a zip file and store it at an external location.
136+
</string>
137+
<string name="restore_setting_title">Restore</string>
138+
<string name="restore_setting_summary">
139+
Choose a zip file to restore projects from. Wipes all existing data.
140+
</string>
141+
133142
<string name="docs">Docs</string>
134143
<string name="report">Report</string>
135144
<string name="licenses">Licenses</string>

0 commit comments

Comments
 (0)