Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/cachelio-debug-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: Cachelio Debug APK

on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:

Expand All @@ -22,6 +21,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Set artifact name
id: meta
run: |
REF="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
SAFE="$(echo "$REF" | tr '/' '-')"
echo "artifact=cachelio-debug-apk-${SAFE}" >> "$GITHUB_OUTPUT"
echo "Building ref: $REF"

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
Expand Down Expand Up @@ -65,7 +72,7 @@ jobs:
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: cachelio-debug-apk
name: ${{ steps.meta.outputs.artifact }}
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 30
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
Cachelio keeps your private library under your control on this device.

- **Hide media** in Files so it stays out of the normal library view
- **Privacy password** (optional) protects Privacy settings and unlocking hidden files
- **Privacy password** (optional) protects Privacy settings and unlocking the current session
- **On-device only** — your password verifier is stored only on this device. There is no cloud account, password reset, or recovery. If you forget it, you must clear the app data
- **Session unlock** — “Show hidden files” lasts for the current app session only. It locks again when the app process restarts
- **Session unlock** — lasts for the current app session only and locks again when the app process restarts. While unlocked, hidden files are shown, and bookmarks added while unlocked are only visible until you unlock again. Bookmark toolbar actions stay available when locked; only private bookmark entries are hidden

## Get the app

Official releases are published on [GitHub Releases](https://github.com/NullMargin/z-grab-browser3/releases) as `cachelio-<tag>.apk` (for example `cachelio-v2.0.3.apk`).

For development builds, install the latest debug APK from the GitHub Actions artifact [`cachelio-debug-apk`](.github/workflows/vbrowser-debug-apk.yml) (workflow: Cachelio Debug APK).
For development builds, install the latest debug APK from the GitHub Actions artifact `cachelio-debug-apk-<branch>` (workflow: [Cachelio Debug APK](.github/workflows/cachelio-debug-apk.yml)). Pushes to any branch build that branch.

Cachelio uses package id `com.holeintimes.vbrowser`, so it installs as an update over previous VBrowser builds and keeps your existing downloads and settings.

Expand All @@ -43,6 +43,6 @@ echo "sdk.dir=$HOME/Android/Sdk" > local.properties

Or open the project in Android Studio (Ladybug+) and sync.

CI: [`.github/workflows/vbrowser-debug-apk.yml`](.github/workflows/vbrowser-debug-apk.yml) builds `assembleDebug` on push/PR and `workflow_dispatch`, and uploads `cachelio-debug-apk`. [`.github/workflows/cachelio-release-apk.yml`](.github/workflows/cachelio-release-apk.yml) builds a signed `assembleRelease` APK and attaches it when a GitHub Release is published.
CI: [`.github/workflows/cachelio-debug-apk.yml`](.github/workflows/cachelio-debug-apk.yml) builds `assembleDebug` on push (any branch), PR, and `workflow_dispatch`, and uploads `cachelio-debug-apk-<branch>`. [`.github/workflows/cachelio-release-apk.yml`](.github/workflows/cachelio-release-apk.yml) builds a signed `assembleRelease` APK and attaches it when a GitHub Release is published.

Reference PRD: [`low-code/docs/BUSINESS_REQUIREMENTS.md`](low-code/docs/BUSINESS_REQUIREMENTS.md)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.holeintimes.vbrowser.domain.AppLanguage
import com.holeintimes.vbrowser.domain.BookmarkEntry
import com.holeintimes.vbrowser.domain.FilesSort
import com.holeintimes.vbrowser.domain.HistoryEntry
import com.holeintimes.vbrowser.domain.UserPreferences
Expand Down Expand Up @@ -46,6 +47,7 @@ class PrefsRepository(private val context: Context) {
val lastPlayedPath = stringPreferencesKey("last_played_path")
val lastBrightness = floatPreferencesKey("last_brightness")
val history = stringPreferencesKey("visit_history")
val bookmarks = stringPreferencesKey("bookmarks")
val urlIndex = stringSetPreferencesKey("url_index")
}

Expand Down Expand Up @@ -178,6 +180,34 @@ class PrefsRepository(private val context: Context) {
context.dataStore.edit { it.remove(Keys.history) }
}

val bookmarks: Flow<List<BookmarkEntry>> = context.dataStore.data.map { p ->
val raw = p[Keys.bookmarks] ?: return@map emptyList()
runCatching { json.decodeFromString<List<BookmarkEntry>>(raw) }.getOrElse { emptyList() }
}

suspend fun addBookmark(entry: BookmarkEntry) {
context.dataStore.edit { prefs ->
val current = prefs[Keys.bookmarks]
?.let { runCatching { json.decodeFromString<List<BookmarkEntry>>(it) }.getOrNull() }
.orEmpty()
val updated = listOf(entry) + current.filterNot {
it.url == entry.url && it.isPrivate == entry.isPrivate
}
prefs[Keys.bookmarks] = json.encodeToString(updated.take(200))
}
}

suspend fun removeBookmark(url: String, isPrivate: Boolean) {
context.dataStore.edit { prefs ->
val current = prefs[Keys.bookmarks]
?.let { runCatching { json.decodeFromString<List<BookmarkEntry>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.bookmarks] = json.encodeToString(
current.filterNot { it.url == url && it.isPrivate == isPrivate }
)
}
}

val downloadedUrls: Flow<Set<String>> = context.dataStore.data.map {
it[Keys.urlIndex] ?: emptySet()
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/holeintimes/vbrowser/domain/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,11 @@ data class HistoryEntry(
val title: String = "",
val visitedAt: Long = System.currentTimeMillis()
)

@Serializable
data class BookmarkEntry(
val url: String,
val title: String = "",
val addedAt: Long = System.currentTimeMillis(),
val isPrivate: Boolean = false
)
114 changes: 111 additions & 3 deletions app/src/main/java/com/holeintimes/vbrowser/ui/browser/BrowserScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,20 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import com.holeintimes.vbrowser.data.sniff.VideoTitleResolver
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
Expand Down Expand Up @@ -80,6 +85,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.holeintimes.vbrowser.R
import com.holeintimes.vbrowser.data.media.MediaLibrary
import com.holeintimes.vbrowser.data.sniff.VideoFormatUtil
import com.holeintimes.vbrowser.domain.BookmarkEntry
import com.holeintimes.vbrowser.domain.VideoInfo
import org.json.JSONArray
import org.json.JSONObject
Expand All @@ -97,6 +103,7 @@ fun BrowserScreen(
var webView by remember { mutableStateOf<WebView?>(null) }
var foundExpanded by remember { mutableStateOf(false) }
var urlEditing by remember { mutableStateOf(false) }
var showBookmarks by remember { mutableStateOf(false) }

LaunchedEffect(state.found.size) {
if (state.found.isNotEmpty() && !foundExpanded) foundExpanded = true
Expand Down Expand Up @@ -145,6 +152,25 @@ fun BrowserScreen(
IconButton(onClick = { webView?.reload() }) {
Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh))
}
IconButton(
onClick = { viewModel.toggleBookmark() },
enabled = state.currentUrl.isNotBlank() && state.currentUrl != HOME_URL
) {
Icon(
imageVector = if (state.isCurrentBookmarked) {
Icons.Filled.Star
} else {
Icons.Filled.StarBorder
},
contentDescription = stringResource(R.string.bookmark)
)
}
IconButton(onClick = { showBookmarks = true }) {
Icon(
Icons.Default.Bookmarks,
contentDescription = stringResource(R.string.bookmarks)
)
}
}
BrowserUrlBar(
currentUrl = state.currentUrl,
Expand Down Expand Up @@ -222,6 +248,88 @@ fun BrowserScreen(
}
}
}

if (showBookmarks) {
BookmarksDialog(
bookmarks = state.visibleBookmarks,
onOpen = { entry ->
showBookmarks = false
viewModel.onUrlSubmitted(entry.url)
},
onRemove = viewModel::removeBookmark,
onDismiss = { showBookmarks = false }
)
}
}

@Composable
private fun BookmarksDialog(
bookmarks: List<BookmarkEntry>,
onOpen: (BookmarkEntry) -> Unit,
onRemove: (BookmarkEntry) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.bookmarks)) },
text = {
if (bookmarks.isEmpty()) {
Text(
stringResource(R.string.no_bookmarks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
items(bookmarks, key = { "${it.isPrivate}:${it.url}" }) { entry ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen(entry) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.title.ifBlank { formatUrlForDisplay(entry.url) },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = formatUrlForDisplay(entry.url),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (entry.isPrivate) {
Icon(
Icons.Default.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
IconButton(onClick = { onRemove(entry) }) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.remove_bookmark)
)
}
}
HorizontalDivider()
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

@OptIn(ExperimentalMaterial3Api::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.holeintimes.vbrowser.AppContainer
import com.holeintimes.vbrowser.data.sniff.VideoFormatUtil
import com.holeintimes.vbrowser.domain.BookmarkEntry
import com.holeintimes.vbrowser.domain.DetectedVideoInfo
import com.holeintimes.vbrowser.domain.HistoryEntry
import com.holeintimes.vbrowser.domain.UserPreferences
Expand All @@ -16,7 +17,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

private const val HOME_URL = "file:///android_asset/home.html"
internal const val HOME_URL = "file:///android_asset/home.html"

data class BrowserUiState(
val currentUrl: String = HOME_URL,
Expand All @@ -29,25 +30,39 @@ data class BrowserUiState(
val downloadedUrls: Set<String> = emptySet(),
val prefs: UserPreferences = UserPreferences(),
val history: List<HistoryEntry> = emptyList(),
val bookmarks: List<BookmarkEntry> = emptyList(),
val privacyUnlocked: Boolean = false,
val pendingLoadUrl: String? = null
)
) {
val visibleBookmarks: List<BookmarkEntry>
get() = if (privacyUnlocked) bookmarks else bookmarks.filterNot { it.isPrivate }

val isCurrentBookmarked: Boolean
get() = visibleBookmarks.any { it.url == currentUrl }
}

class BrowserViewModel(private val container: AppContainer) : ViewModel() {
private val _ui = MutableStateFlow(BrowserUiState())

val uiState: StateFlow<BrowserUiState> = combine(
_ui,
container.sniffer.foundList,
container.prefs.downloadedUrls,
container.prefs.preferences,
container.prefs.history
) { ui, found, urls, prefs, history ->
ui.copy(
found = found,
downloadedUrls = urls,
prefs = prefs,
history = history
)
combine(
_ui,
container.sniffer.foundList,
container.prefs.downloadedUrls,
container.prefs.preferences,
container.prefs.history
) { ui, found, urls, prefs, history ->
ui.copy(
found = found,
downloadedUrls = urls,
prefs = prefs,
history = history
)
},
container.prefs.bookmarks,
container.privacySession.isUnlocked
) { ui, bookmarks, unlocked ->
ui.copy(bookmarks = bookmarks, privacyUnlocked = unlocked)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BrowserUiState())

init {
Expand Down Expand Up @@ -177,6 +192,32 @@ class BrowserViewModel(private val container: AppContainer) : ViewModel() {

fun openExternalUrl(url: String) = onUrlSubmitted(url)

fun toggleBookmark() {
val state = uiState.value
val url = state.currentUrl
if (url.isBlank() || url == HOME_URL) return
val existing = state.visibleBookmarks.firstOrNull { it.url == url }
viewModelScope.launch {
if (existing != null) {
container.prefs.removeBookmark(existing.url, existing.isPrivate)
} else {
container.prefs.addBookmark(
BookmarkEntry(
url = url,
title = state.pageTitle,
isPrivate = state.privacyUnlocked
)
)
}
}
}

fun removeBookmark(entry: BookmarkEntry) {
viewModelScope.launch {
container.prefs.removeBookmark(entry.url, entry.isPrivate)
}
}

private fun normalizeUrl(raw: String): String {
val t = raw.trim()
if (t.isEmpty() || t == HOME_URL) return HOME_URL
Expand Down
Loading
Loading