Address PR Feedback
This commit is contained in:
parent
13b10b6e52
commit
65ce28cbd7
@ -70,6 +70,7 @@ import org.schabi.newpipe.util.SecondaryStreamHelper;
|
||||
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
|
||||
import org.schabi.newpipe.util.StreamLabelUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.io.File;
|
||||
@ -1132,7 +1133,8 @@ public class DownloadDialog extends DialogFragment
|
||||
);
|
||||
}
|
||||
|
||||
final String qualityLabel = buildQualityLabel(selectedStream);
|
||||
final String qualityLabel =
|
||||
StreamLabelUtils.getQualityLabel(requireContext(), selectedStream);
|
||||
|
||||
DownloadManagerService.startMission(
|
||||
context,
|
||||
@ -1155,24 +1157,4 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
dismiss();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String buildQualityLabel(@NonNull final Stream stream) {
|
||||
if (stream instanceof VideoStream) {
|
||||
return ((VideoStream) stream).getResolution();
|
||||
} else if (stream instanceof AudioStream) {
|
||||
final int bitrate = ((AudioStream) stream).getAverageBitrate();
|
||||
return bitrate > 0 ? bitrate + "kbps" : null;
|
||||
} else if (stream instanceof SubtitlesStream) {
|
||||
final SubtitlesStream subtitlesStream = (SubtitlesStream) stream;
|
||||
final String language = subtitlesStream.getDisplayLanguageName();
|
||||
if (subtitlesStream.isAutoGenerated()) {
|
||||
return language + " (" + getString(R.string.caption_auto_generated) + ")";
|
||||
}
|
||||
return language;
|
||||
}
|
||||
|
||||
final MediaFormat format = stream.getFormat();
|
||||
return format != null ? format.getSuffix() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,26 +23,43 @@ import us.shandian.giga.service.MissionState
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
sealed interface DownloadStatus {
|
||||
data object None : DownloadStatus
|
||||
data class InProgress(val running: Boolean) : DownloadStatus
|
||||
data class Completed(val info: CompletedDownload) : DownloadStatus
|
||||
enum class DownloadStage {
|
||||
Pending,
|
||||
Running,
|
||||
Finished
|
||||
}
|
||||
|
||||
data class CompletedDownload(
|
||||
data class DownloadHandle(
|
||||
val serviceId: Int,
|
||||
val url: String,
|
||||
val streamUid: Long,
|
||||
val storageUri: Uri?,
|
||||
val timestamp: Long,
|
||||
val kind: Char
|
||||
)
|
||||
|
||||
data class DownloadEntry(
|
||||
val handle: DownloadHandle,
|
||||
val displayName: String?,
|
||||
val qualityLabel: String?,
|
||||
val mimeType: String?,
|
||||
val fileUri: Uri?,
|
||||
val parentUri: Uri?,
|
||||
val fileAvailable: Boolean
|
||||
val fileAvailable: Boolean,
|
||||
val stage: DownloadStage
|
||||
)
|
||||
|
||||
object DownloadStatusRepository {
|
||||
|
||||
fun observe(context: Context, serviceId: Int, url: String): Flow<DownloadStatus> = callbackFlow {
|
||||
/**
|
||||
* Keeps a one-off binding to [DownloadManagerService] alive for as long as the caller stays
|
||||
* subscribed. We prime the channel with the latest persisted snapshot and then forward every
|
||||
* mission event emitted by the service-bound handler. Once the consumer cancels the flow we
|
||||
* make sure to unregister the handler and unbind the service to avoid leaking the connection.
|
||||
*/
|
||||
fun observe(context: Context, serviceId: Int, url: String): Flow<List<DownloadEntry>> = callbackFlow {
|
||||
if (serviceId < 0 || url.isBlank()) {
|
||||
trySend(DownloadStatus.None)
|
||||
trySend(emptyList())
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
@ -50,6 +67,9 @@ object DownloadStatusRepository {
|
||||
val appContext = context.applicationContext
|
||||
val intent = Intent(appContext, DownloadManagerService::class.java)
|
||||
appContext.startService(intent)
|
||||
// The download manager service only notifies listeners while a client is bound, so the flow
|
||||
// keeps a foreground-style binding alive for its entire lifetime. Holding on to
|
||||
// applicationContext avoids leaking short-lived UI contexts.
|
||||
var binder: DownloadManagerBinder? = null
|
||||
var registeredCallback: Handler.Callback? = null
|
||||
|
||||
@ -57,19 +77,23 @@ object DownloadStatusRepository {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val downloadBinder = service as? DownloadManagerBinder
|
||||
if (downloadBinder == null) {
|
||||
trySend(DownloadStatus.None)
|
||||
trySend(emptyList())
|
||||
appContext.unbindService(this)
|
||||
close()
|
||||
return
|
||||
}
|
||||
binder = downloadBinder
|
||||
trySend(downloadBinder.getDownloadStatus(serviceId, url, false).toDownloadStatus())
|
||||
// First delivery: snapshot persisted on disk so the UI paints immediately even
|
||||
// before the service emits new events.
|
||||
trySend(downloadBinder.getDownloadStatuses(serviceId, url, true).toDownloadEntries())
|
||||
|
||||
val callback = Handler.Callback { message: Message ->
|
||||
val mission = message.obj
|
||||
if (mission.matches(serviceId, url)) {
|
||||
val snapshot = downloadBinder.getDownloadStatus(serviceId, url, false)
|
||||
trySend(snapshot.toDownloadStatus())
|
||||
// Each mission event carries opaque state, so we fetch a fresh snapshot to
|
||||
// guarantee consistent entries while the download progresses or finishes.
|
||||
val snapshots = downloadBinder.getDownloadStatuses(serviceId, url, false)
|
||||
trySend(snapshots.toDownloadEntries())
|
||||
}
|
||||
false
|
||||
}
|
||||
@ -80,48 +104,57 @@ object DownloadStatusRepository {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
|
||||
binder = null
|
||||
trySend(DownloadStatus.None)
|
||||
trySend(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
val bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
if (!bound) {
|
||||
trySend(DownloadStatus.None)
|
||||
trySend(emptyList())
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
// When the collector disappears we remove listeners and unbind immediately to avoid
|
||||
// holding the service forever; the service will rebind on the next subscription.
|
||||
registeredCallback?.let { callback -> binder?.removeMissionEventListener(callback) }
|
||||
runCatching { appContext.unbindService(connection) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh(context: Context, serviceId: Int, url: String): DownloadStatus {
|
||||
if (serviceId < 0 || url.isBlank()) return DownloadStatus.None
|
||||
suspend fun refresh(context: Context, serviceId: Int, url: String): List<DownloadEntry> {
|
||||
if (serviceId < 0 || url.isBlank()) return emptyList()
|
||||
return withBinder(context) { binder ->
|
||||
binder.getDownloadStatus(serviceId, url, true).toDownloadStatus()
|
||||
binder.getDownloadStatuses(serviceId, url, true).toDownloadEntries()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFile(context: Context, serviceId: Int, url: String): Boolean {
|
||||
if (serviceId < 0 || url.isBlank()) return false
|
||||
suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean {
|
||||
if (handle.serviceId < 0 || handle.url.isBlank()) return false
|
||||
return withBinder(context) { binder ->
|
||||
binder.deleteFinishedMission(serviceId, url, true)
|
||||
binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLink(context: Context, serviceId: Int, url: String): Boolean {
|
||||
if (serviceId < 0 || url.isBlank()) return false
|
||||
suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean {
|
||||
if (handle.serviceId < 0 || handle.url.isBlank()) return false
|
||||
return withBinder(context) { binder ->
|
||||
binder.deleteFinishedMission(serviceId, url, false)
|
||||
binder.deleteFinishedMission(handle.serviceId, handle.url, handle.storageUri, handle.timestamp, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that briefly binds to [DownloadManagerService], executes [block] against its binder
|
||||
* and tears everything down in one place. All callers should use this to prevent scattering
|
||||
* ad-hoc bind/unbind logic across the codebase.
|
||||
*/
|
||||
private suspend fun <T> withBinder(context: Context, block: (DownloadManagerBinder) -> T): T {
|
||||
val appContext = context.applicationContext
|
||||
val intent = Intent(appContext, DownloadManagerService::class.java)
|
||||
appContext.startService(intent)
|
||||
// The direct call path still needs the service running long enough to complete the
|
||||
// binder transaction, so we explicitly start it before establishing the short-lived bind.
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
@ -176,32 +209,83 @@ object DownloadStatusRepository {
|
||||
|
||||
@VisibleForTesting
|
||||
@MainThread
|
||||
internal fun DownloadManager.DownloadStatusSnapshot?.toDownloadStatus(): DownloadStatus {
|
||||
if (this == null || state == MissionState.None) {
|
||||
return DownloadStatus.None
|
||||
}
|
||||
return when (state) {
|
||||
MissionState.Pending, MissionState.PendingRunning ->
|
||||
DownloadStatus.InProgress(state == MissionState.PendingRunning)
|
||||
MissionState.Finished -> {
|
||||
val mission = finishedMission
|
||||
if (mission == null) {
|
||||
DownloadStatus.None
|
||||
} else {
|
||||
val storage = mission.storage
|
||||
val hasStorage = storage != null && !storage.isInvalid()
|
||||
val info = CompletedDownload(
|
||||
displayName = storage?.getName(),
|
||||
qualityLabel = mission.qualityLabel,
|
||||
mimeType = if (hasStorage) storage!!.getType() else null,
|
||||
fileUri = if (hasStorage) storage!!.getUri() else null,
|
||||
parentUri = if (hasStorage) storage!!.getParentUri() else null,
|
||||
fileAvailable = fileExists && hasStorage
|
||||
)
|
||||
DownloadStatus.Completed(info)
|
||||
}
|
||||
internal fun List<DownloadManager.DownloadStatusSnapshot>.toDownloadEntries(): List<DownloadEntry> {
|
||||
return buildList {
|
||||
for (snapshot in this@toDownloadEntries) {
|
||||
snapshot.toDownloadEntry()?.let { add(it) }
|
||||
}
|
||||
else -> DownloadStatus.None
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@MainThread
|
||||
internal fun DownloadManager.DownloadStatusSnapshot.toDownloadEntry(): DownloadEntry? {
|
||||
val stage = when (state) {
|
||||
MissionState.Pending -> DownloadStage.Pending
|
||||
MissionState.PendingRunning -> DownloadStage.Running
|
||||
MissionState.Finished -> DownloadStage.Finished
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val (metadata, storage) = when (stage) {
|
||||
DownloadStage.Finished -> finishedMission?.let {
|
||||
MissionMetadata(
|
||||
serviceId = it.serviceId,
|
||||
url = it.source,
|
||||
streamUid = it.streamUid,
|
||||
timestamp = it.timestamp,
|
||||
kind = it.kind,
|
||||
qualityLabel = it.qualityLabel
|
||||
) to it.storage
|
||||
}
|
||||
else -> pendingMission?.let {
|
||||
MissionMetadata(
|
||||
serviceId = it.serviceId,
|
||||
url = it.source,
|
||||
streamUid = it.streamUid,
|
||||
timestamp = it.timestamp,
|
||||
kind = it.kind,
|
||||
qualityLabel = it.qualityLabel
|
||||
) to it.storage
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
val hasStorage = storage != null && !storage.isInvalid()
|
||||
val fileUri = storage?.getUri()
|
||||
val parentUri = storage?.getParentUri()
|
||||
|
||||
val handle = DownloadHandle(
|
||||
serviceId = metadata.serviceId,
|
||||
url = metadata.url ?: "",
|
||||
streamUid = metadata.streamUid,
|
||||
storageUri = fileUri,
|
||||
timestamp = metadata.timestamp,
|
||||
kind = metadata.kind
|
||||
)
|
||||
|
||||
val fileAvailable = when (stage) {
|
||||
DownloadStage.Finished -> hasStorage && fileExists
|
||||
DownloadStage.Pending, DownloadStage.Running -> false
|
||||
}
|
||||
|
||||
return DownloadEntry(
|
||||
handle = handle,
|
||||
displayName = storage?.getName(),
|
||||
qualityLabel = metadata.qualityLabel,
|
||||
mimeType = if (hasStorage) storage.getType() else null,
|
||||
fileUri = fileUri,
|
||||
parentUri = parentUri,
|
||||
fileAvailable = fileAvailable,
|
||||
stage = stage
|
||||
)
|
||||
}
|
||||
|
||||
private data class MissionMetadata(
|
||||
val serviceId: Int,
|
||||
val url: String?,
|
||||
val streamUid: Long,
|
||||
val timestamp: Long,
|
||||
val kind: Char,
|
||||
val qualityLabel: String?
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
package org.schabi.newpipe.download.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
@ -14,66 +17,134 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.download.CompletedDownload
|
||||
import org.schabi.newpipe.fragments.detail.DownloadChipState
|
||||
import org.schabi.newpipe.download.DownloadEntry
|
||||
import org.schabi.newpipe.download.DownloadStage
|
||||
import org.schabi.newpipe.fragments.detail.DownloadUiState
|
||||
import org.schabi.newpipe.fragments.detail.isPending
|
||||
import org.schabi.newpipe.fragments.detail.isRunning
|
||||
import us.shandian.giga.util.Utility
|
||||
import us.shandian.giga.util.Utility.FileType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DownloadStatusHost(
|
||||
state: DownloadUiState,
|
||||
onChipClick: () -> Unit,
|
||||
onChipClick: (DownloadEntry) -> Unit,
|
||||
onDismissSheet: () -> Unit,
|
||||
onOpenFile: (CompletedDownload) -> Unit,
|
||||
onDeleteFile: (CompletedDownload) -> Unit,
|
||||
onRemoveLink: (CompletedDownload) -> Unit,
|
||||
onShowInFolder: (CompletedDownload) -> Unit
|
||||
onOpenFile: (DownloadEntry) -> Unit,
|
||||
onDeleteFile: (DownloadEntry) -> Unit,
|
||||
onRemoveLink: (DownloadEntry) -> Unit,
|
||||
onShowInFolder: (DownloadEntry) -> Unit
|
||||
) {
|
||||
val chipState = state.chipState
|
||||
val selected = state.selected
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
if (state.isSheetVisible && chipState is DownloadChipState.Downloaded) {
|
||||
if (state.isSheetVisible && selected != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissSheet,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
DownloadSheetContent(
|
||||
info = chipState.info,
|
||||
onOpenFile = { onOpenFile(chipState.info) },
|
||||
onDeleteFile = { onDeleteFile(chipState.info) },
|
||||
onRemoveLink = { onRemoveLink(chipState.info) },
|
||||
onShowInFolder = { onShowInFolder(chipState.info) }
|
||||
entry = selected,
|
||||
onOpenFile = { onOpenFile(selected) },
|
||||
onDeleteFile = { onDeleteFile(selected) },
|
||||
onRemoveLink = { onRemoveLink(selected) },
|
||||
onShowInFolder = { onShowInFolder(selected) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (chipState) {
|
||||
DownloadChipState.Hidden -> Unit
|
||||
DownloadChipState.Downloading -> AssistChip(
|
||||
onClick = onChipClick,
|
||||
label = { Text(text = stringResource(id = R.string.download_status_downloading)) }
|
||||
)
|
||||
is DownloadChipState.Downloaded -> {
|
||||
val label = chipState.info.qualityLabel
|
||||
val text = if (!label.isNullOrBlank()) {
|
||||
stringResource(R.string.download_status_downloaded, label)
|
||||
} else {
|
||||
stringResource(R.string.download_status_downloaded_simple)
|
||||
}
|
||||
AssistChip(
|
||||
onClick = onChipClick,
|
||||
label = { Text(text = text) }
|
||||
)
|
||||
if (state.entries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
FlowRow(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||
state.entries.forEach { entry ->
|
||||
DownloadChip(entry = entry, onClick = { onChipClick(entry) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadChip(entry: DownloadEntry, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val type = Utility.getFileType(entry.handle.kind, entry.displayName ?: "")
|
||||
val backgroundColor = Utility.getBackgroundForFileType(context, type)
|
||||
val stripeColor = Utility.getForegroundForFileType(context, type)
|
||||
|
||||
val typeLabelRes = when (type) {
|
||||
FileType.MUSIC -> R.string.download_type_audio
|
||||
FileType.VIDEO -> R.string.download_type_video
|
||||
FileType.SUBTITLE -> R.string.download_type_captions
|
||||
FileType.UNKNOWN -> R.string.download_type_media
|
||||
}
|
||||
val typeLabel = stringResource(typeLabelRes)
|
||||
|
||||
val stageText = when (entry.stage) {
|
||||
DownloadStage.Finished -> stringResource(R.string.download_status_downloaded_type, typeLabel)
|
||||
DownloadStage.Running -> stringResource(R.string.download_status_downloading_type, typeLabel)
|
||||
DownloadStage.Pending -> stringResource(R.string.download_status_pending_type, typeLabel)
|
||||
}
|
||||
|
||||
val chipText = entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { "$stageText • $it" } ?: stageText
|
||||
|
||||
val chipShape = MaterialTheme.shapes.small
|
||||
|
||||
val baseModifier = Modifier
|
||||
.padding(end = 8.dp, bottom = 8.dp)
|
||||
.clip(chipShape)
|
||||
.drawWithContent {
|
||||
if (entry.stage == DownloadStage.Finished) {
|
||||
drawRect(Color(backgroundColor))
|
||||
drawContent()
|
||||
} else if (entry.isPending) {
|
||||
drawRect(Color(backgroundColor))
|
||||
val stripePaint = Color(stripeColor).copy(alpha = 0.35f)
|
||||
val stripeWidth = 12.dp.toPx()
|
||||
var offset = -size.height
|
||||
val diagonal = size.height
|
||||
while (offset < size.width + size.height) {
|
||||
drawLine(
|
||||
color = stripePaint,
|
||||
start = Offset(offset, 0f),
|
||||
end = Offset(offset + diagonal, diagonal),
|
||||
strokeWidth = stripeWidth
|
||||
)
|
||||
offset += stripeWidth * 2f
|
||||
}
|
||||
drawContent()
|
||||
} else {
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
|
||||
val labelColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val chipColors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.Transparent,
|
||||
labelColor = labelColor
|
||||
)
|
||||
|
||||
AssistChip(
|
||||
onClick = onClick,
|
||||
label = { Text(text = chipText) },
|
||||
colors = chipColors,
|
||||
modifier = baseModifier,
|
||||
border = null
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadSheetContent(
|
||||
info: CompletedDownload,
|
||||
entry: DownloadEntry,
|
||||
onOpenFile: () -> Unit,
|
||||
onDeleteFile: () -> Unit,
|
||||
onRemoveLink: () -> Unit,
|
||||
@ -84,13 +155,22 @@ private fun DownloadSheetContent(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
val title = info.displayName ?: stringResource(id = R.string.download)
|
||||
val title = entry.displayName ?: stringResource(id = R.string.download)
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
val subtitleParts = buildList {
|
||||
info.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
if (!info.fileAvailable) {
|
||||
add(stringResource(id = R.string.download_status_missing))
|
||||
entry.qualityLabel?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
when (entry.stage) {
|
||||
DownloadStage.Finished -> if (!entry.fileAvailable) {
|
||||
add(stringResource(id = R.string.download_status_missing))
|
||||
}
|
||||
DownloadStage.Pending, DownloadStage.Running -> {
|
||||
if (entry.isRunning) {
|
||||
add(stringResource(R.string.download_status_downloading))
|
||||
} else {
|
||||
add(stringResource(R.string.missions_header_pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subtitleParts.isNotEmpty()) {
|
||||
@ -104,21 +184,24 @@ private fun DownloadSheetContent(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
val showFileActions = info.fileAvailable && info.fileUri != null
|
||||
val showFileActions = entry.fileAvailable && entry.fileUri != null
|
||||
if (showFileActions) {
|
||||
TextButton(onClick = onOpenFile) {
|
||||
Text(text = stringResource(id = R.string.download_action_open))
|
||||
Text(text = stringResource(id = R.string.open_with))
|
||||
}
|
||||
TextButton(onClick = onShowInFolder, enabled = info.parentUri != null) {
|
||||
TextButton(onClick = onShowInFolder, enabled = entry.parentUri != null) {
|
||||
Text(text = stringResource(id = R.string.download_action_show_in_folder))
|
||||
}
|
||||
TextButton(onClick = onDeleteFile) {
|
||||
Text(text = stringResource(id = R.string.download_action_delete))
|
||||
Text(text = stringResource(id = R.string.delete_file))
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onRemoveLink) {
|
||||
Text(text = stringResource(id = R.string.download_action_remove_link), color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = onRemoveLink, enabled = entry.stage == DownloadStage.Finished) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.delete_entry),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@ -67,8 +67,8 @@ import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
|
||||
import org.schabi.newpipe.download.CompletedDownload
|
||||
import org.schabi.newpipe.download.DownloadDialog
|
||||
import org.schabi.newpipe.download.DownloadEntry
|
||||
import org.schabi.newpipe.download.ui.DownloadStatusHost
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
|
||||
@ -288,14 +288,12 @@ class VideoDetailFragment :
|
||||
val composeContext = LocalContext.current
|
||||
DownloadStatusHost(
|
||||
state = uiState,
|
||||
onChipClick = {
|
||||
downloadStatusViewModel.onChipClicked(composeContext.applicationContext)
|
||||
},
|
||||
onChipClick = { entry -> downloadStatusViewModel.onChipSelected(entry) },
|
||||
onDismissSheet = { downloadStatusViewModel.dismissSheet() },
|
||||
onOpenFile = { info -> openDownloaded(info) },
|
||||
onDeleteFile = { info -> deleteDownloadedFile(info) },
|
||||
onRemoveLink = { info -> removeDownloadLink(info) },
|
||||
onShowInFolder = { info -> showDownloadedInFolder(info) }
|
||||
onOpenFile = { entry -> openDownloaded(entry) },
|
||||
onDeleteFile = { entry -> deleteDownloadedFile(entry) },
|
||||
onRemoveLink = { entry -> removeDownloadLink(entry) },
|
||||
onShowInFolder = { entry -> showDownloadedInFolder(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1578,29 +1576,29 @@ class VideoDetailFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDownloaded(info: CompletedDownload) {
|
||||
val uri = info.fileUri
|
||||
private fun openDownloaded(entry: DownloadEntry) {
|
||||
val uri = entry.fileUri
|
||||
if (uri == null) {
|
||||
Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, info.mimeType ?: "*/*")
|
||||
setDataAndType(uri, entry.mimeType ?: "*/*")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
runCatching { startActivity(intent) }
|
||||
.onFailure {
|
||||
if (DEBUG) Log.e(TAG, "Failed to open downloaded file", it)
|
||||
Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(requireContext(), R.string.missing_file, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDownloadedInFolder(info: CompletedDownload) {
|
||||
val parent = info.parentUri
|
||||
private fun showDownloadedInFolder(entry: DownloadEntry) {
|
||||
val parent = entry.parentUri
|
||||
if (parent == null) {
|
||||
Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(requireContext(), R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
@ -1619,37 +1617,29 @@ class VideoDetailFragment :
|
||||
runCatching { startActivity(treeIntent) }
|
||||
.onFailure { throwable ->
|
||||
if (DEBUG) Log.e(TAG, "Failed to open folder", throwable)
|
||||
Toast.makeText(context, R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDownloadedFile(info: CompletedDownload) {
|
||||
if (!info.fileAvailable) {
|
||||
Toast.makeText(requireContext(), R.string.download_delete_failed, Toast.LENGTH_SHORT).show()
|
||||
private fun deleteDownloadedFile(entry: DownloadEntry) {
|
||||
if (!entry.fileAvailable) {
|
||||
Toast.makeText(requireContext(), R.string.general_error, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val appContext = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = downloadStatusViewModel.deleteFile(appContext)
|
||||
val message = if (success) {
|
||||
R.string.download_deleted
|
||||
} else {
|
||||
R.string.download_delete_failed
|
||||
}
|
||||
val success = downloadStatusViewModel.deleteFile(appContext, entry.handle)
|
||||
val message = if (success) R.string.file_deleted else R.string.general_error
|
||||
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeDownloadLink(@Suppress("UNUSED_PARAMETER") info: CompletedDownload) {
|
||||
private fun removeDownloadLink(entry: DownloadEntry) {
|
||||
val appContext = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = downloadStatusViewModel.removeLink(appContext)
|
||||
val message = if (success) {
|
||||
R.string.download_link_removed
|
||||
} else {
|
||||
R.string.download_delete_failed
|
||||
}
|
||||
val success = downloadStatusViewModel.removeLink(appContext, entry.handle)
|
||||
val message = if (success) R.string.entry_deleted else R.string.general_error
|
||||
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.schabi.newpipe.download.CompletedDownload
|
||||
import org.schabi.newpipe.download.DownloadStatus
|
||||
import org.schabi.newpipe.download.DownloadEntry
|
||||
import org.schabi.newpipe.download.DownloadHandle
|
||||
import org.schabi.newpipe.download.DownloadStage
|
||||
import org.schabi.newpipe.download.DownloadStatusRepository
|
||||
|
||||
class VideoDownloadStatusViewModel : ViewModel() {
|
||||
@ -44,76 +45,70 @@ class VideoDownloadStatusViewModel : ViewModel() {
|
||||
|
||||
observeJob?.cancel()
|
||||
observeJob = viewModelScope.launch {
|
||||
DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl).collectLatest { status ->
|
||||
_uiState.update { it.copy(chipState = status.toChipState()) }
|
||||
}
|
||||
DownloadStatusRepository.observe(appContext, serviceId, normalizedUrl)
|
||||
.collectLatest { entries ->
|
||||
_uiState.update { current ->
|
||||
val selectedHandle = current.selected?.handle
|
||||
val newSelected = selectedHandle?.let { handle ->
|
||||
entries.firstOrNull { it.handle == handle }
|
||||
}
|
||||
current.copy(entries = entries, selected = newSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onChipClicked(context: Context) {
|
||||
val url = currentUrl ?: return
|
||||
val serviceId = currentServiceId
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
DownloadStatusRepository.refresh(context.applicationContext, serviceId, url)
|
||||
}
|
||||
result.getOrNull()?.let { status ->
|
||||
_uiState.update {
|
||||
val chipState = status.toChipState()
|
||||
it.copy(
|
||||
chipState = chipState,
|
||||
isSheetVisible = chipState is DownloadChipState.Downloaded
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.isFailure) {
|
||||
_uiState.update { it.copy(isSheetVisible = false) }
|
||||
}
|
||||
}
|
||||
fun onChipSelected(entry: DownloadEntry) {
|
||||
_uiState.update { it.copy(selected = entry) }
|
||||
}
|
||||
|
||||
fun dismissSheet() {
|
||||
_uiState.update { it.copy(isSheetVisible = false) }
|
||||
_uiState.update { it.copy(selected = null) }
|
||||
}
|
||||
|
||||
suspend fun deleteFile(context: Context): Boolean {
|
||||
val url = currentUrl ?: return false
|
||||
val serviceId = currentServiceId
|
||||
suspend fun deleteFile(context: Context, handle: DownloadHandle): Boolean {
|
||||
val success = runCatching {
|
||||
DownloadStatusRepository.deleteFile(context.applicationContext, serviceId, url)
|
||||
DownloadStatusRepository.deleteFile(context.applicationContext, handle)
|
||||
}.getOrDefault(false)
|
||||
if (success) {
|
||||
_uiState.update { it.copy(isSheetVisible = false) }
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
entries = state.entries.filterNot { it.handle == handle },
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
suspend fun removeLink(context: Context): Boolean {
|
||||
val url = currentUrl ?: return false
|
||||
val serviceId = currentServiceId
|
||||
suspend fun removeLink(context: Context, handle: DownloadHandle): Boolean {
|
||||
val success = runCatching {
|
||||
DownloadStatusRepository.removeLink(context.applicationContext, serviceId, url)
|
||||
DownloadStatusRepository.removeLink(context.applicationContext, handle)
|
||||
}.getOrDefault(false)
|
||||
if (success) {
|
||||
_uiState.update { it.copy(isSheetVisible = false) }
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
entries = state.entries.filterNot { it.handle == handle },
|
||||
selected = null
|
||||
)
|
||||
}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
private fun DownloadStatus.toChipState(): DownloadChipState = when (this) {
|
||||
DownloadStatus.None -> DownloadChipState.Hidden
|
||||
is DownloadStatus.InProgress -> DownloadChipState.Downloading
|
||||
is DownloadStatus.Completed -> DownloadChipState.Downloaded(info)
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadUiState(
|
||||
val chipState: DownloadChipState = DownloadChipState.Hidden,
|
||||
val isSheetVisible: Boolean = false
|
||||
)
|
||||
|
||||
sealed interface DownloadChipState {
|
||||
data object Hidden : DownloadChipState
|
||||
data object Downloading : DownloadChipState
|
||||
data class Downloaded(val info: CompletedDownload) : DownloadChipState
|
||||
val entries: List<DownloadEntry> = emptyList(),
|
||||
val selected: DownloadEntry? = null
|
||||
) {
|
||||
val isSheetVisible: Boolean get() = selected != null
|
||||
}
|
||||
|
||||
val DownloadEntry.isPending: Boolean
|
||||
get() = when (stage) {
|
||||
DownloadStage.Pending, DownloadStage.Running -> true
|
||||
DownloadStage.Finished -> false
|
||||
}
|
||||
|
||||
val DownloadEntry.isRunning: Boolean
|
||||
get() = stage == DownloadStage.Running
|
||||
|
||||
@ -134,7 +134,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
||||
|
||||
if (stream instanceof VideoStream) {
|
||||
final VideoStream videoStream = ((VideoStream) stream);
|
||||
qualityString = videoStream.getResolution();
|
||||
|
||||
if (hasAnyVideoOnlyStreamWithNoSecondaryStream) {
|
||||
if (videoStream.isVideoOnly()) {
|
||||
@ -149,24 +148,13 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
||||
woSoundIconVisibility = View.INVISIBLE;
|
||||
}
|
||||
}
|
||||
} else if (stream instanceof AudioStream) {
|
||||
final AudioStream audioStream = ((AudioStream) stream);
|
||||
if (audioStream.getAverageBitrate() > 0) {
|
||||
qualityString = audioStream.getAverageBitrate() + "kbps";
|
||||
} else {
|
||||
qualityString = context.getString(R.string.unknown_quality);
|
||||
}
|
||||
} else if (stream instanceof SubtitlesStream) {
|
||||
qualityString = ((SubtitlesStream) stream).getDisplayLanguageName();
|
||||
if (((SubtitlesStream) stream).isAutoGenerated()) {
|
||||
qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")";
|
||||
}
|
||||
} else {
|
||||
if (mediaFormat == null) {
|
||||
qualityString = context.getString(R.string.unknown_quality);
|
||||
} else {
|
||||
qualityString = mediaFormat.getSuffix();
|
||||
}
|
||||
woSoundIconVisibility = View.GONE;
|
||||
}
|
||||
|
||||
qualityString = StreamLabelUtils.getQualityLabel(context, stream);
|
||||
if (qualityString == null) {
|
||||
qualityString = context.getString(R.string.unknown_quality);
|
||||
}
|
||||
|
||||
if (streamsWrapper.getSizeInBytes(position) > 0) {
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package org.schabi.newpipe.util
|
||||
|
||||
import android.content.Context
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||
import org.schabi.newpipe.extractor.stream.Stream
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||
|
||||
object StreamLabelUtils {
|
||||
@JvmStatic
|
||||
fun getQualityLabel(context: Context, stream: Stream): String? = when (stream) {
|
||||
is VideoStream -> stream.resolution?.takeIf { it.isNotBlank() }
|
||||
is AudioStream -> {
|
||||
val bitrate = stream.averageBitrate
|
||||
if (bitrate > 0) "$bitrate kbps" else null
|
||||
}
|
||||
is SubtitlesStream -> {
|
||||
val language = stream.displayLanguageName
|
||||
if (language.isNullOrBlank()) {
|
||||
null
|
||||
} else if (stream.isAutoGenerated) {
|
||||
"$language (${context.getString(R.string.caption_auto_generated)})"
|
||||
} else {
|
||||
language
|
||||
}
|
||||
}
|
||||
else -> stream.format?.suffix?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package us.shandian.giga.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.util.Log;
|
||||
|
||||
@ -363,6 +364,30 @@ public class DownloadManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private FinishedMission getFinishedMission(Uri storageUri) {
|
||||
String uriString = storageUri.toString();
|
||||
for (FinishedMission mission : mMissionsFinished) {
|
||||
if (mission.storage != null && !mission.storage.isInvalid()) {
|
||||
Uri missionUri = mission.storage.getUri();
|
||||
if (missionUri != null && uriString.equals(missionUri.toString())) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private FinishedMission getFinishedMission(long timestamp) {
|
||||
for (FinishedMission mission : mMissionsFinished) {
|
||||
if (mission.timestamp == timestamp) {
|
||||
return mission;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isFileAvailable(@NonNull FinishedMission mission) {
|
||||
if (mission.storage == null || mission.storage.isInvalid()) {
|
||||
return false;
|
||||
@ -474,27 +499,56 @@ public class DownloadManager {
|
||||
}
|
||||
|
||||
DownloadStatusSnapshot getDownloadStatus(int serviceId, String url, boolean revalidateFile) {
|
||||
List<DownloadStatusSnapshot> snapshots = getDownloadStatuses(serviceId, url, revalidateFile);
|
||||
if (snapshots.isEmpty()) {
|
||||
return new DownloadStatusSnapshot(MissionState.None, null, null, false);
|
||||
}
|
||||
return snapshots.get(0);
|
||||
}
|
||||
|
||||
List<DownloadStatusSnapshot> getDownloadStatuses(int serviceId, String url, boolean revalidateFile) {
|
||||
List<DownloadStatusSnapshot> result = new ArrayList<>();
|
||||
synchronized (this) {
|
||||
DownloadMission pending = getPendingMission(serviceId, url);
|
||||
if (pending != null) {
|
||||
MissionState state = pending.running
|
||||
? MissionState.PendingRunning
|
||||
: MissionState.Pending;
|
||||
return new DownloadStatusSnapshot(state, pending, null, true);
|
||||
for (DownloadMission mission : mMissionsPending) {
|
||||
if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) {
|
||||
MissionState state = mission.running
|
||||
? MissionState.PendingRunning
|
||||
: MissionState.Pending;
|
||||
result.add(new DownloadStatusSnapshot(state, mission, null, true));
|
||||
}
|
||||
}
|
||||
|
||||
FinishedMission finished = getFinishedMission(serviceId, url);
|
||||
if (finished != null) {
|
||||
boolean available = !revalidateFile || isFileAvailable(finished);
|
||||
return new DownloadStatusSnapshot(MissionState.Finished, null, finished, available);
|
||||
for (FinishedMission mission : mMissionsFinished) {
|
||||
if (mission.serviceId == serviceId && Objects.equals(mission.source, url)) {
|
||||
boolean available = !revalidateFile || isFileAvailable(mission);
|
||||
result.add(new DownloadStatusSnapshot(MissionState.Finished, null, mission, available));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DownloadStatusSnapshot(MissionState.None, null, null, false);
|
||||
if (result.isEmpty()) {
|
||||
result.add(new DownloadStatusSnapshot(MissionState.None, null, null, false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
boolean deleteFinishedMission(int serviceId, String url, boolean deleteFile) {
|
||||
FinishedMission mission = getFinishedMission(serviceId, url);
|
||||
return deleteFinishedMission(serviceId, url, null, -1L, deleteFile);
|
||||
}
|
||||
|
||||
boolean deleteFinishedMission(int serviceId, String url, @Nullable Uri storageUri,
|
||||
long timestamp, boolean deleteFile) {
|
||||
FinishedMission mission = null;
|
||||
if (storageUri != null) {
|
||||
mission = getFinishedMission(storageUri);
|
||||
}
|
||||
if (mission == null && timestamp > 0) {
|
||||
mission = getFinishedMission(timestamp);
|
||||
}
|
||||
if (mission == null) {
|
||||
mission = getFinishedMission(serviceId, url);
|
||||
}
|
||||
if (mission == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.download.DownloadActivity;
|
||||
import org.schabi.newpipe.player.helper.LockManager;
|
||||
@ -601,8 +603,14 @@ public class DownloadManagerService extends Service {
|
||||
return mManager.getDownloadStatus(serviceId, source, revalidateFile);
|
||||
}
|
||||
|
||||
public boolean deleteFinishedMission(int serviceId, String source, boolean deleteFile) {
|
||||
return mManager.deleteFinishedMission(serviceId, source, deleteFile);
|
||||
public List<DownloadManager.DownloadStatusSnapshot> getDownloadStatuses(int serviceId,
|
||||
String source, boolean revalidateFile) {
|
||||
return mManager.getDownloadStatuses(serviceId, source, revalidateFile);
|
||||
}
|
||||
|
||||
public boolean deleteFinishedMission(int serviceId, String source, @Nullable Uri storageUri,
|
||||
long timestamp, boolean deleteFile) {
|
||||
return mManager.deleteFinishedMission(serviceId, source, storageUri, timestamp, deleteFile);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -18,17 +18,16 @@
|
||||
<string name="controls_download_desc">Download stream file</string>
|
||||
<string name="download_status_downloaded">Downloaded • %1$s</string>
|
||||
<string name="download_status_downloaded_simple">Downloaded</string>
|
||||
<string name="download_status_downloaded_type">Downloaded %1$s</string>
|
||||
<string name="download_status_downloading_type">Downloading %1$s</string>
|
||||
<string name="download_status_pending_type">Pending %1$s</string>
|
||||
<string name="download_type_audio">Audio</string>
|
||||
<string name="download_type_video">Video</string>
|
||||
<string name="download_type_captions">Captions</string>
|
||||
<string name="download_type_media">Media</string>
|
||||
<string name="download_status_downloading">Downloading…</string>
|
||||
<string name="download_status_missing">Previously downloaded – file missing</string>
|
||||
<string name="download_action_open">Open file</string>
|
||||
<string name="download_action_show_in_folder">Show in folder</string>
|
||||
<string name="download_action_delete">Delete file</string>
|
||||
<string name="download_action_remove_link">Remove link</string>
|
||||
<string name="download_link_removed">Download link removed</string>
|
||||
<string name="download_open_failed">Unable to open downloaded file</string>
|
||||
<string name="download_folder_open_failed">Unable to open folder</string>
|
||||
<string name="download_delete_failed">Unable to delete downloaded file</string>
|
||||
<string name="download_deleted">Deleted downloaded file</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="search_with_service_name">Search %1$s</string>
|
||||
<string name="search_with_service_name_and_filter">Search %1$s (%2$s)</string>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user