mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
storage: a Manage storage screen in App info that clears lyrics, voice models and old logs one at a time
This commit is contained in:
1 parent
b2a95adb87
commit
46c2e92684
2 files changed
+205
No files matched your search
@@ -126,6 +126,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="${appLabel}"
|
||||
android:supportsRtl="true"
|
||||
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
<provider
|
||||
@@ -162,6 +163,12 @@
|
||||
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||
-->
|
||||
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
||||
<activity
|
||||
android:name=".ui.ManageSpaceActivity"
|
||||
android:exported="true"
|
||||
android:label="Gearslip storage" />
|
||||
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.media.Lyrics
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The screen behind "Manage storage" on Gearslip's page in the phone's App info › Storage.
|
||||
* Clears each kind of stored data on its own: the lyrics cache, the voice models, old logs and
|
||||
* other temporary files. Some Android versions drop their own "Clear storage" button once an app
|
||||
* has this screen, so deleting everything is offered here too.
|
||||
*
|
||||
* The manifest names it in full: a relative name would be read against the installed package,
|
||||
* and the debug build's ".dev" package has no such class.
|
||||
*/
|
||||
class ManageSpaceActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { GearslipTheme { StorageScreen(onBack = ::finish) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** What each kind of stored data takes up, measured on a background thread. */
|
||||
private class Usage(val lyricsSongs: Int, val lyrics: Long, val voice: Long, val logs: Long, val other: Long) {
|
||||
val total get() = lyrics + voice + logs + other
|
||||
}
|
||||
|
||||
private fun File.size(): Long = walkBottomUp().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
private fun lyricsDir(context: Context) = File(context.cacheDir, Lyrics.LYRICS_DIR)
|
||||
private fun voiceDir(context: Context) = File(context.filesDir, "speech")
|
||||
|
||||
/** Every launch's log but the newest, which is the one this launch is still writing. */
|
||||
private fun oldLogs(context: Context): List<File> {
|
||||
val dir = context.getExternalFilesDir(null) ?: return emptyList()
|
||||
val logs = dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||
?.sortedByDescending { it.name }.orEmpty()
|
||||
return logs.drop(1) + listOfNotNull(File(context.cacheDir, "shared").takeIf { it.exists() })
|
||||
}
|
||||
|
||||
/** The cache directory without the lyrics: downloaded updates, shared logs, spoken directions. */
|
||||
private fun otherCache(context: Context): List<File> =
|
||||
context.cacheDir.listFiles()?.filter { it.name != Lyrics.LYRICS_DIR && it.name != "shared" }.orEmpty()
|
||||
|
||||
private fun measure(context: Context) = Usage(
|
||||
lyricsSongs = lyricsDir(context).listFiles()?.size ?: 0,
|
||||
lyrics = lyricsDir(context).size(),
|
||||
voice = voiceDir(context).size(),
|
||||
logs = oldLogs(context).sumOf { it.size() },
|
||||
other = otherCache(context).sumOf { it.size() },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StorageScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
var usage by remember { mutableStateOf<Usage?>(null) }
|
||||
LaunchedEffect(refresh) { usage = withContext(Dispatchers.IO) { measure(context) } }
|
||||
fun clear(work: () -> Unit) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { work() }
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
fun size(bytes: Long) = Formatter.formatShortFileSize(context, bytes)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Gearslip storage") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
val u = usage
|
||||
Text(
|
||||
if (u == null) "Measuring…" else "Gearslip's data takes ${size(u.total)} of the phone's storage.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SettingsCard {
|
||||
StorageRow(
|
||||
"Lyrics",
|
||||
u?.let { "${it.lyricsSongs} songs · ${size(it.lyrics)}. Cleared lyrics download again when you open them." },
|
||||
enabled = (u?.lyrics ?: 0) > 0,
|
||||
action = "Clear",
|
||||
) { clear { Lyrics.clearCache(context) } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Voice models",
|
||||
u?.let { "${size(it.voice)}. Voice commands need them; download them again in Settings." },
|
||||
enabled = (u?.voice ?: 0) > 0,
|
||||
action = "Remove",
|
||||
) {
|
||||
clear {
|
||||
voiceDir(context).deleteRecursively()
|
||||
SpeechModels.init(context)
|
||||
}
|
||||
}
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Old logs",
|
||||
u?.let { "${size(it.logs)}. Logs from earlier launches; this launch's log stays." },
|
||||
enabled = (u?.logs ?: 0) > 0,
|
||||
action = "Clear",
|
||||
) { clear { oldLogs(context).forEach { it.deleteRecursively() } } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Other temporary files",
|
||||
u?.let { "${size(it.other)}. Downloaded updates and other files Gearslip can make again." },
|
||||
enabled = (u?.other ?: 0) > 0,
|
||||
action = "Clear",
|
||||
) { clear { otherCache(context).forEach { it.deleteRecursively() } } }
|
||||
}
|
||||
DeleteEverything()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageRow(title: String, detail: String?, enabled: Boolean, action: String, onClick: () -> Unit) {
|
||||
SettingsRow(
|
||||
title,
|
||||
subtitle = detail ?: "Measuring…",
|
||||
trailing = { TextButton(onClick = onClick, enabled = enabled) { Text(action) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Android's "Clear storage", which this screen replaces: two taps, so it can't happen by accident. */
|
||||
@Composable
|
||||
private fun DeleteEverything() {
|
||||
val context = LocalContext.current
|
||||
var armed by remember { mutableStateOf(false) }
|
||||
SettingsCard {
|
||||
SettingsRow(
|
||||
"Delete all Gearslip data",
|
||||
subtitle = if (armed) {
|
||||
"Tap Delete again to erase settings, certificates, voice models and logs. Gearslip closes, and starts from setup next time."
|
||||
} else {
|
||||
"Settings, certificates, voice models, lyrics and logs."
|
||||
},
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
trailing = {
|
||||
TextButton(onClick = {
|
||||
if (armed) context.getSystemService(ActivityManager::class.java)?.clearApplicationUserData()
|
||||
else armed = true
|
||||
}) { Text(if (armed) "Delete" else "Delete…", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user