Initial ReSearch export
This commit is contained in:
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)
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user