Initial ReSearch export

This commit is contained in:
OneWay committed 2026-07-06 09:29:50 +08:00
commit e8a49575c1
145 files changed
+14188

No files matched your search

@@ -0,0 +1,134 @@
package com.example.research.common.progress
import android.content.Context
import androidx.annotation.StringRes
import com.example.research.R
import com.example.research.core.domain.model.IndexingProgress
import com.example.research.feature.download.model.DownloadState
import com.example.research.ui.settings.ImportState
object DictionaryProgressModel {
// Weights for pipeline stages. Sum must equal 1.0.
private const val DOWNLOAD_WEIGHT = 0.4f
private const val EXTRACT_WEIGHT = 0.2f
private const val INDEX_WEIGHT = 0.4f
private const val IMPORT_COPY_WEIGHT = 0.25f
private const val IMPORT_EXTRACT_WEIGHT = 0.25f
private const val IMPORT_INDEX_WEIGHT = 0.5f
data class Snapshot(
val progress: Float,
val percent: Int,
@param:StringRes val titleRes: Int,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Snapshot) return false
return percent == other.percent && titleRes == other.titleRes
}
override fun hashCode(): Int {
return 31 * percent + titleRes
}
}
fun computeProgressSnapshot(
downloadState: DownloadState,
downloadProgress: Float,
importState: ImportState,
indexingProgress: IndexingProgress,
): Snapshot? {
val dl = downloadProgress.coerceIn(0f, 1f)
val isIndexing = indexingProgress.isIndexing
val isImportFlow = importState is ImportState.Importing ||
importState is ImportState.Extracting ||
importState is ImportState.Success
val isLoading = downloadState is DownloadState.Loading
val isExtracting = downloadState is DownloadState.Extracting
val isWaitingForIndexing = downloadState is DownloadState.Success && !isIndexing
return when {
isImportFlow && isIndexing -> indexingSnapshot(indexingProgress, fromImport = true)
importState is ImportState.Importing -> importCopySnapshot(importState)
importState is ImportState.Extracting -> importExtractSnapshot(importState)
importState is ImportState.Success -> importWaitingSnapshot()
isLoading -> loadingSnapshot(dl)
isExtracting -> extractingSnapshot(dl)
isWaitingForIndexing -> waitingSnapshot()
isIndexing -> indexingSnapshot(indexingProgress, fromImport = false)
else -> null
}
}
private fun importCopySnapshot(state: ImportState.Importing): Snapshot {
val p = state.progress.coerceIn(0f, 1f) * IMPORT_COPY_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun importExtractSnapshot(state: ImportState.Extracting): Snapshot {
val p = IMPORT_COPY_WEIGHT + state.progress.coerceIn(0f, 1f) * IMPORT_EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun importWaitingSnapshot(): Snapshot {
val p = IMPORT_COPY_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_import_title,
)
}
private fun loadingSnapshot(dl: Float): Snapshot {
val p = dl * DOWNLOAD_WEIGHT
val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot(
progress = p,
percent = pct,
titleRes = R.string.notification_download_title,
)
}
private fun extractingSnapshot(dl: Float): Snapshot {
val p = DOWNLOAD_WEIGHT + dl * EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_download_title,
)
}
private fun waitingSnapshot(): Snapshot {
val p = DOWNLOAD_WEIGHT + EXTRACT_WEIGHT
return Snapshot(
progress = p,
percent = (p * 100f).toInt().coerceIn(0, 100),
titleRes = R.string.notification_indexing_title,
)
}
private fun indexingSnapshot(indexing: IndexingProgress, fromImport: Boolean = false): Snapshot {
val idx = indexing.progress
val p = if (fromImport) {
IMPORT_COPY_WEIGHT + IMPORT_EXTRACT_WEIGHT + idx * IMPORT_INDEX_WEIGHT
} else {
DOWNLOAD_WEIGHT + EXTRACT_WEIGHT + idx * INDEX_WEIGHT
}
val pct = (p * 100f).toInt().coerceIn(0, 100)
return Snapshot(
progress = p,
percent = pct,
titleRes = R.string.notification_indexing_title,
)
}
}
fun DictionaryProgressModel.Snapshot.renderTitle(context: Context): String =
context.getString(titleRes)
@@ -0,0 +1,34 @@
package com.example.research.common.progress
import com.example.research.data.repository.LocalDictionaryRepository
import com.example.research.feature.download.DownloadManager
import com.example.research.feature.import.DictionaryImportManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
class DictionaryProgressStateHolder(
scope: CoroutineScope,
downloadManager: DownloadManager,
dictionaryImportManager: DictionaryImportManager,
localDictionaryRepository: LocalDictionaryRepository,
) {
val progressSnapshot: StateFlow<DictionaryProgressModel.Snapshot?> =
combine(
downloadManager.downloadProgressState,
dictionaryImportManager.importState,
localDictionaryRepository.indexingProgress,
) { download, importState, indexingProgress ->
DictionaryProgressModel.computeProgressSnapshot(
downloadState = download.state,
downloadProgress = download.progress,
importState = importState,
indexingProgress = indexingProgress,
)
}
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, null)
}
@@ -0,0 +1,52 @@
package com.example.research.common.ui
import android.content.Context
import android.content.ContextWrapper
import android.content.res.AssetManager
import android.content.res.Configuration
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import java.util.Locale
private class LocalizedContextWrapper(
base: Context,
locale: Locale
) : ContextWrapper(base) {
private val localizedResources: Resources = run {
val config = Configuration(base.resources.configuration).apply {
setLocale(locale)
setLayoutDirection(locale)
}
base.createConfigurationContext(config).resources
}
override fun getResources(): Resources = localizedResources
override fun getAssets(): AssetManager = localizedResources.assets
}
@Composable
fun AppLocaleProvider(language: String, content: @Composable () -> Unit) {
val baseContext = LocalContext.current
val baseConfig = LocalConfiguration.current
val wrapped = remember(language, baseContext, baseConfig) {
val locale = if (language == "system") {
Resources.getSystem().configuration.locales[0]
} else {
Locale.forLanguageTag(language)
}
LocalizedContextWrapper(baseContext, locale)
}
val wrappedConfig = remember(wrapped) { wrapped.resources.configuration }
CompositionLocalProvider(
LocalContext provides wrapped,
LocalConfiguration provides wrappedConfig
) {
content()
}
}
@@ -0,0 +1,39 @@
package com.example.research.common.ui
import androidx.compose.foundation.text.LocalBackgroundTextMeasurementExecutor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import java.util.concurrent.Executors
/**
* Pre-warms Android's text layout caches away from the UI thread.
*
* Compose applies its own device and text-length heuristics before scheduling work, so the
* executor can safely be shared by all text rendered under this provider. A single worker keeps
* text prefetch from competing with UI work when both the search results and an article are visible.
*/
@Composable
fun BackgroundTextMeasurementProvider(
content: @Composable () -> Unit,
) {
val executor = remember {
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "ReSearch-text-measurement").apply {
priority = Thread.NORM_PRIORITY - 1
}
}
}
DisposableEffect(executor) {
onDispose {
executor.shutdownNow()
}
}
CompositionLocalProvider(
LocalBackgroundTextMeasurementExecutor provides executor,
content = content,
)
}
@@ -0,0 +1,26 @@
package com.example.research.common.ui
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
fun Modifier.appTestTag(tag: String): Modifier = this
.testTag(tag)
.semantics { testTagsAsResourceId = true }
@Composable
fun Modifier.clickableWithHaptics(
hapticType: HapticFeedbackType = HapticFeedbackType.TextHandleMove,
onClick: () -> Unit
): Modifier {
val haptic = LocalHapticFeedback.current
return this.clickable {
haptic.performHapticFeedback(hapticType)
onClick()
}
}
@@ -0,0 +1,81 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.example.research.R
import com.example.research.common.ui.clickableWithHaptics
@Composable
fun CollapsibleHeader(
title: String,
iconRes: Int,
isExpanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
testTag: String? = null
) {
Row(
modifier = modifier
.fillMaxWidth()
.then(testTag?.let { Modifier.testTag(it) } ?: Modifier)
.clickableWithHaptics(
hapticType = HapticFeedbackType.TextHandleMove,
onClick = onToggle
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Icon(
imageVector = if (isExpanded) {
ImageVector.vectorResource(R.drawable.ic_keyboard_arrow_up)
} else {
ImageVector.vectorResource(R.drawable.ic_keyboard_arrow_down)
},
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,76 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
message: String,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)? = null,
description: String? = null,
primaryAction: EmptyStateAction? = null,
secondaryAction: EmptyStateAction? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
icon?.invoke()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = message,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
primaryAction?.let { action ->
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(onClick = action.onClick) {
Text(action.label)
}
secondaryAction?.let { secondary ->
OutlinedButton(onClick = secondary.onClick) {
Text(secondary.label)
}
}
}
}
}
}
data class EmptyStateAction(
val label: String,
val onClick: () -> Unit
)
@@ -0,0 +1,77 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun OutlinedChoiceButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
fillWidth: Boolean = true,
highlighted: Boolean = false,
enforceMinimumInteractiveSize: Boolean = true,
) {
val minimumInteractiveSize = if (enforceMinimumInteractiveSize) {
LocalMinimumInteractiveComponentSize.current
} else {
0.dp
}
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides minimumInteractiveSize
) {
Surface(
onClick = onClick,
modifier = modifier.heightIn(min = 32.dp),
shape = FilterChipDefaults.shape,
color = if (highlighted) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Transparent
},
border = BorderStroke(
width = 1.dp,
color = if (highlighted || selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
}
)
) {
Box(
modifier = Modifier
.then(if (fillWidth) Modifier.fillMaxWidth() else Modifier)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = when {
highlighted -> MaterialTheme.colorScheme.onPrimaryContainer
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
@@ -0,0 +1,51 @@
package com.example.research.common.ui.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
variant: SectionCardVariant = SectionCardVariant.Default,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = when (variant) {
SectionCardVariant.Default -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
SectionCardVariant.Primary -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
SectionCardVariant.Error -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
colors = colors,
shape = MaterialTheme.shapes.medium,
content = content
)
} else {
Card(
modifier = modifier,
colors = colors,
shape = MaterialTheme.shapes.medium,
content = content
)
}
}
enum class SectionCardVariant {
Default,
Primary,
Error
}
@@ -0,0 +1,8 @@
package com.example.research.common.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,57 @@
package com.example.research.common.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun ReSearchTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val view = LocalView.current
val isInEditMode = view.isInEditMode
val colorScheme = when {
dynamicColor -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
if (!isInEditMode) {
SideEffect {
val activity = view.context as? Activity
activity?.let {
val window = it.window
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography
) {
content()
}
}
@@ -0,0 +1,87 @@
package com.example.research.common.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
@@ -0,0 +1,67 @@
package com.example.research.common.util
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.example.research.R
import kotlinx.coroutines.*
import java.util.zip.GZIPInputStream
object ArchiveUtils {
suspend fun extractGzWithBom(
context: Context,
sourceUri: Uri,
destinationUri: Uri
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(sourceUri)
?: return@withContext Result.failure(Exception(context.getString(R.string.archive_failed_open_source_file)))
inputStream.use { rawInput ->
val outputStream = context.contentResolver.openOutputStream(destinationUri, "wt")
?: return@withContext Result.failure(Exception(context.getString(R.string.archive_failed_create_output_file)))
GZIPInputStream(rawInput).use { gzipInput ->
val bufferedInput = java.io.BufferedInputStream(gzipInput)
outputStream.use { out ->
val detectedCharset = com.example.research.data.parser.DslCharsetDetector.detect(bufferedInput)
if (detectedCharset == com.example.research.data.parser.DslCharsetDetector.DetectedCharset.UTF8_NO_BOM) {
out.write(byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()))
}
val buffer = ByteArray(64 * 1024)
var read: Int
var bytesSinceYield = 0
while (bufferedInput.read(buffer).also { read = it } != -1) {
out.write(buffer, 0, read)
bytesSinceYield += read
if (bytesSinceYield >= 2 * 1024 * 1024) {
yield()
bytesSinceYield = 0
}
}
}
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteFile(context: Context, uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
try {
val file = if (uri.scheme == "file") {
val path = uri.path ?: return@withContext Result.failure(Exception(context.getString(R.string.error_invalid_file_path)))
DocumentFile.fromFile(java.io.File(path))
} else {
DocumentFile.fromSingleUri(context, uri)
}
if (file != null && file.exists() && file.delete()) {
Result.success(Unit)
} else if (file != null && !file.exists()) {
Result.success(Unit)
} else {
Result.failure(Exception(context.getString(R.string.archive_failed_delete_file, file?.name ?: uri.toString())))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,28 @@
package com.example.research.common.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
object DateUtils {
private val dateFormat = SimpleDateFormat("yyMMdd", Locale.US)
fun getCurrentDateString(): String {
return dateFormat.format(Calendar.getInstance().time)
}
fun getPreviousDateString(): String {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -1)
return dateFormat.format(calendar.time)
}
fun extractDateFromFileName(fileName: String): String? {
val regex = Regex("""_(\d{6})\.(gz|dsl|idx|dsl\.dz|dsl\.idx|dsl\.gz)$""")
return regex.find(fileName)?.groupValues?.get(1)
}
fun isDateBefore(date1: String, date2: String): Boolean {
return try {
val d1 = dateFormat.parse(date1) ?: return false
val d2 = dateFormat.parse(date2) ?: return false
d1.before(d2)
} catch (_: Exception) {
false
}
}
}
@@ -0,0 +1,177 @@
package com.example.research.common.util
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
data class DocumentFileInfo(
val name: String,
val uri: Uri
)
class FileStorageManager {
companion object {
private const val DELETE_RETRY_DELAY_BASE = 100L
private const val DELETE_WAIT_INTERVAL = 50L
private const val MAX_DELETE_WAIT_COUNT = 10
private const val DEFAULT_MAX_RETRIES = 5
private const val TAG = "FileStorageManager"
}
private val folderCache = ConcurrentHashMap<Uri, DocumentFile>()
private fun invalidateCache(folderUri: Uri) {
folderCache.remove(folderUri)
}
private fun getCachedFolder(folderUri: Uri): DocumentFile? {
val cached = folderCache[folderUri]
if (cached != null && cached.exists()) {
return cached
}
val file = File(folderUri.path ?: return null)
if (!file.exists()) file.mkdirs()
val folder = DocumentFile.fromFile(file)
folderCache[folderUri] = folder
return folder
}
suspend fun listFilesInFolder(folderUri: Uri): List<DocumentFileInfo> = withContext(Dispatchers.IO) {
try {
val folder = getCachedFolder(folderUri) ?: return@withContext emptyList()
if (!folder.exists() || !folder.isDirectory) {
invalidateCache(folderUri)
return@withContext emptyList()
}
val files = folder.listFiles().mapNotNull { file ->
file.name?.let { name ->
DocumentFileInfo(
name = name,
uri = file.uri
)
}
}
files
} catch (e: Exception) {
Log.e(TAG, "Error listing files: ${e.message}", e)
invalidateCache(folderUri)
emptyList()
}
}
suspend fun createFileInFolder(folderUri: Uri, fileName: String, overwrite: Boolean = true): Uri? = withContext(Dispatchers.IO) {
try {
if (SafeFileName.validate(fileName) == null) {
Log.e(TAG, "Rejected unsafe file name: $fileName")
return@withContext null
}
val folderPath = folderUri.path ?: run {
Log.e(TAG, "Invalid folder URI: $folderUri")
return@withContext null
}
val folderFile = File(folderPath)
if (!folderFile.exists()) {
folderFile.mkdirs()
}
if (!folderFile.isDirectory) {
Log.e(TAG, "Path is not a directory: $folderPath")
return@withContext null
}
val targetFile = File(folderFile, fileName)
if (targetFile.exists()) {
if (overwrite) {
if (!targetFile.delete()) {
Log.e(TAG, "Could not delete existing file: $fileName")
return@withContext null
}
} else {
return@withContext Uri.fromFile(targetFile)
}
}
val tempBinFile = File(folderFile, "$fileName.bin")
if (tempBinFile.exists()) {
tempBinFile.delete()
}
if (targetFile.createNewFile()) {
Uri.fromFile(targetFile)
} else {
Log.e(TAG, "Failed to create file: $fileName")
null
}
} catch (e: Exception) {
Log.e(TAG, "Error creating file: ${e.message}", e)
invalidateCache(folderUri)
null
}
}
suspend fun forceDeleteFile(file: DocumentFile, fileName: String, maxRetries: Int = DEFAULT_MAX_RETRIES): Boolean = withContext(Dispatchers.IO) {
if (!file.exists()) {
return@withContext true
}
for (attempt in 1..maxRetries) {
try {
if (file.delete()) {
waitForFileDeletion(file)
if (!file.exists()) {
return@withContext true
}
}
if (attempt < maxRetries) {
delay((DELETE_RETRY_DELAY_BASE * attempt).milliseconds)
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting file $fileName (attempt $attempt): ${e.message}")
if (attempt < maxRetries) {
delay((DELETE_RETRY_DELAY_BASE * attempt).milliseconds)
}
}
}
if (!file.exists()) {
true
} else {
Log.e(TAG, "Failed to delete file after $maxRetries attempts: $fileName")
false
}
}
private suspend fun waitForFileDeletion(file: DocumentFile) {
var waitCount = 0
while (file.exists() && waitCount < MAX_DELETE_WAIT_COUNT) {
delay(DELETE_WAIT_INTERVAL.milliseconds)
waitCount++
}
}
suspend fun getDocumentFile(uri: Uri): DocumentFile? = withContext(Dispatchers.IO) {
runCatching {
val file = File(uri.path ?: return@withContext null)
DocumentFile.fromFile(file)
}.onFailure { e ->
Log.e(TAG, "Error getting DocumentFile: ${e.message}", e)
}.getOrNull()
}
suspend fun findFile(folderUri: Uri, fileName: String): DocumentFile? = withContext(Dispatchers.IO) {
val folder = getCachedFolder(folderUri) ?: return@withContext null
folder.findFile(fileName)
}
suspend fun forceDeleteFileByName(folderUri: Uri, fileName: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val folder = getCachedFolder(folderUri) ?: return@withContext false
val file = folder.findFile(fileName)
if (file == null || !file.exists()) {
return@withContext true
}
forceDeleteFile(file, fileName)
}.onFailure { e ->
Log.e(TAG, "Error in forceDeleteFileByName for $fileName", e)
}.getOrDefault(false)
}
}
@@ -0,0 +1,169 @@
package com.example.research.common.util
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
import androidx.core.app.*
import androidx.core.content.ContextCompat
import com.example.research.MainActivity
import com.example.research.R
import com.example.research.feature.download.receiver.DownloadCancelReceiver
class NotificationHelper(private val context: Context) {
companion object {
const val CHANNEL_ID = "download_progress_channel"
const val NOTIFICATION_ID = 1
}
private val notificationManager = NotificationManagerCompat.from(context)
private var lastNotificationKey: Int = 0
private val mainActivityIntent by lazy {
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
private val mainActivityPendingIntent by lazy {
PendingIntent.getActivity(
context,
0,
mainActivityIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
private val cancelIntent by lazy {
Intent(context, DownloadCancelReceiver::class.java).apply {
action = DownloadCancelReceiver.ACTION_CANCEL_DOWNLOAD
}
}
private val cancelPendingIntent by lazy {
PendingIntent.getBroadcast(
context,
0,
cancelIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_download_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notification_channel_download_description)
setShowBadge(false)
importance = NotificationManager.IMPORTANCE_LOW
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.createNotificationChannel(channel)
}
private fun canShowNotification(): Boolean {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
fun showSuccessNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_download_success_title))
.setContentText(context.getString(R.string.notification_download_success_text))
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun showErrorNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_download_error_title))
.setContentText(context.getString(R.string.notification_download_error_text))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun cancelNotification() {
lastNotificationKey = 0
notificationManager.cancel(NOTIFICATION_ID)
}
fun showUnifiedProgressNotification(
title: String,
contentText: String,
progressPercent: Int,
) {
if (!canShowNotification()) return
val progressClamped = progressPercent.coerceIn(0, 100)
val key = notificationKey(title, contentText, progressClamped)
if (key == lastNotificationKey && progressClamped != 0 && progressClamped != 100) {
return
}
lastNotificationKey = key
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setProgress(100, progressClamped, false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(false)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.button_cancel_download),
cancelPendingIntent
)
safeNotify(builder.build())
}
private fun notificationKey(
title: String,
contentText: String,
percent: Int,
): Int {
var h = percent
h = 31 * h + title.hashCode()
h = 31 * h + contentText.hashCode()
return if (h == 0) 1 else h
}
@SuppressLint("MissingPermission")
private fun safeNotify(notification: Notification) {
try {
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
// Notification permission denied or security policy blocked
}
}
fun showImportSuccessNotification() {
if (!canShowNotification()) return
lastNotificationKey = 0
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_import_success_title))
.setContentText(context.getString(R.string.notification_import_success_text))
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(mainActivityPendingIntent)
.setAutoCancel(true)
.build()
safeNotify(notification)
}
fun createForegroundNotification(): Notification {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(context.getString(R.string.notification_channel_download_name))
.setContentText(context.getString(R.string.notification_download_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setContentIntent(mainActivityPendingIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setProgress(100, 0, true)
.build()
return notification
}
}
@@ -0,0 +1,12 @@
package com.example.research.common.util
object SafeFileName {
fun validate(rawName: String): String? {
if (rawName.isEmpty()) return null
if (rawName.contains('/') || rawName.contains('\\')) return null
if (rawName.contains('\u0000')) return null
if (rawName == "." || rawName == "..") return null
return rawName
}
}