From 46c2e92684af882585e571024a490cbddbdcb310 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 25 Oct 2026 15:30:47 -0700 Subject: [PATCH] storage: a Manage storage screen in App info that clears lyrics, voice models and old logs one at a time --- gearslip/src/main/AndroidManifest.xml | 7 + .../gearslip/ui/ManageSpaceActivity.kt | 198 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/ManageSpaceActivity.kt diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index b54c265..f2c27a6 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -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"> + + + { + 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 = + 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(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) } + }, + ) + } +}