init: live notifications for downloads

This commit is contained in:
tapframe 2026-04-11 22:34:23 +05:30
parent e4a5f5b1ac
commit c0bce25f29
16 changed files with 961 additions and 0 deletions

View file

@ -35,6 +35,10 @@
android:path="/trakt" />
</intent-filter>
</activity>
<receiver
android:name=".features.downloads.DownloadsNotificationActionReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -13,6 +13,7 @@ import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
import com.nuvio.app.features.downloads.DownloadsStorage
import com.nuvio.app.features.library.LibraryStorage
@ -73,6 +74,7 @@ class MainActivity : ComponentActivity() {
CollectionStorage.initialize(applicationContext)
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)
DownloadsLiveStatusPlatform.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this)

View file

@ -0,0 +1,258 @@
package com.nuvio.app.features.downloads
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl
import kotlin.math.abs
internal actual object DownloadsLiveStatusPlatform {
private const val channelId = "downloads_live_status"
private const val channelName = "Downloads"
private const val channelDescription = "Shows live download progress and controls."
private const val notificationsPrefName = "nuvio_download_live_notifications"
private const val trackedDownloadIdsKey = "tracked_download_ids"
private var appContext: Context? = null
private val lastRenderStateById = mutableMapOf<String, RenderState>()
fun initialize(context: Context) {
appContext = context.applicationContext
ensureNotificationChannel()
}
actual fun onItemsChanged(items: List<DownloadItem>) {
val context = appContext ?: return
if (!canPostNotifications(context)) return
val manager = NotificationManagerCompat.from(context)
val trackedBefore = preferences(context)
.getStringSet(trackedDownloadIdsKey, emptySet())
.orEmpty()
.toMutableSet()
val activeItems = items.filter { item ->
item.status == DownloadStatus.Downloading ||
item.status == DownloadStatus.Paused ||
item.status == DownloadStatus.Failed
}
val trackedNow = mutableSetOf<String>()
activeItems.forEach { item ->
val renderState = RenderState(
status = item.status,
progressPercent = progressPercent(item),
downloadedBucket = item.downloadedBytes / (512L * 1024L),
totalBytes = item.totalBytes,
errorMessage = item.errorMessage,
)
val existingState = lastRenderStateById[item.id]
if (existingState == renderState) {
trackedNow += item.id
return@forEach
}
manager.notify(notificationId(item.id), buildNotification(context, item))
lastRenderStateById[item.id] = renderState
trackedNow += item.id
}
val staleIds = trackedBefore - trackedNow
staleIds.forEach { downloadId ->
manager.cancel(notificationId(downloadId))
lastRenderStateById.remove(downloadId)
}
preferences(context)
.edit()
.putStringSet(trackedDownloadIdsKey, trackedNow)
.apply()
}
private fun buildNotification(context: Context, item: DownloadItem): android.app.Notification {
val subtitle = buildSubtitle(item)
val launchIntent = Intent(context, com.nuvio.app.MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(buildDownloadsDeepLinkUrl())
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val launchPendingIntent = PendingIntent.getActivity(
context,
notificationId(item.id),
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(com.nuvio.app.R.drawable.ic_notification_small)
.setContentTitle(item.title)
.setContentText(subtitle)
.setStyle(NotificationCompat.BigTextStyle().bigText(subtitle))
.setOnlyAlertOnce(true)
.setContentIntent(launchPendingIntent)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
when (item.status) {
DownloadStatus.Downloading -> {
notificationBuilder
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(
0,
"Pause",
buildActionPendingIntent(
context = context,
action = DownloadsNotificationActionReceiver.actionPause,
downloadId = item.id,
),
)
val progress = progressPercent(item)
if (progress >= 0) {
notificationBuilder.setProgress(100, progress, false)
} else {
notificationBuilder.setProgress(100, 0, true)
}
}
DownloadStatus.Paused,
DownloadStatus.Failed,
DownloadStatus.Completed,
-> {
notificationBuilder
.setOngoing(false)
.setAutoCancel(false)
.setPriority(
if (item.status == DownloadStatus.Failed) {
NotificationCompat.PRIORITY_DEFAULT
} else {
NotificationCompat.PRIORITY_LOW
},
)
.setProgress(0, 0, false)
.addAction(
0,
"Resume",
buildActionPendingIntent(
context = context,
action = DownloadsNotificationActionReceiver.actionResume,
downloadId = item.id,
),
)
}
}
return notificationBuilder.build()
}
private fun buildSubtitle(item: DownloadItem): String {
val detail = item.displaySubtitle
return when (item.status) {
DownloadStatus.Downloading -> {
val downloaded = formatBytes(item.downloadedBytes)
val total = item.totalBytes?.let(::formatBytes)
if (total != null) {
"Downloading $detail$downloaded / $total"
} else {
"Downloading $detail$downloaded"
}
}
DownloadStatus.Paused -> "Paused $detail"
DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed"
DownloadStatus.Completed -> "Download completed"
}
}
private fun formatBytes(bytes: Long): String {
val safe = bytes.coerceAtLeast(0L).toDouble()
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = safe
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
return if (unitIndex == 0) {
"${value.toLong()} ${units[unitIndex]}"
} else {
"${"%.1f".format(value)} ${units[unitIndex]}"
}
}
private fun progressPercent(item: DownloadItem): Int {
val total = item.totalBytes?.takeIf { it > 0L } ?: return -1
return ((item.downloadedBytes.toDouble() / total.toDouble()) * 100.0)
.toInt()
.coerceIn(0, 100)
}
private fun buildActionPendingIntent(
context: Context,
action: String,
downloadId: String,
): PendingIntent {
val intent = Intent(context, DownloadsNotificationActionReceiver::class.java).apply {
this.action = action
putExtra(DownloadsNotificationActionReceiver.extraDownloadId, downloadId)
}
return PendingIntent.getBroadcast(
context,
notificationId("$action:$downloadId"),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun ensureNotificationChannel() {
val context = appContext ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return
if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel(
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply {
description = channelDescription
},
)
}
private fun canPostNotifications(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionState = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permissionState != PackageManager.PERMISSION_GRANTED) {
return false
}
}
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
private fun preferences(context: Context) =
context.getSharedPreferences(notificationsPrefName, Context.MODE_PRIVATE)
private fun notificationId(downloadId: String): Int = abs(downloadId.hashCode())
private data class RenderState(
val status: DownloadStatus,
val progressPercent: Int,
val downloadedBucket: Long,
val totalBytes: Long?,
val errorMessage: String?,
)
}

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.downloads
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class DownloadsNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action ?: return
val downloadId = intent.getStringExtra(extraDownloadId)?.trim().orEmpty()
if (downloadId.isBlank()) return
DownloadsStorage.initialize(context.applicationContext)
DownloadsPlatformDownloader.initialize(context.applicationContext)
DownloadsLiveStatusPlatform.initialize(context.applicationContext)
DownloadsRepository.ensureLoaded()
when (action) {
actionPause -> DownloadsRepository.pauseDownload(downloadId)
actionResume -> DownloadsRepository.resumeDownload(downloadId)
}
}
companion object {
const val actionPause = "com.nuvio.app.downloads.action.PAUSE"
const val actionResume = "com.nuvio.app.downloads.action.RESUME"
const val extraDownloadId = "download_id"
}
}

View file

@ -491,6 +491,14 @@ private fun MainAppContent(
AppDeepLinkRepository.markConsumed(deepLink)
}
AppDeepLink.Downloads -> {
selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) {
launchSingleTop = true
}
AppDeepLinkRepository.markConsumed(deepLink)
}
null -> Unit
}
}

View file

@ -12,6 +12,8 @@ sealed interface AppDeepLink {
val type: String,
val id: String,
) : AppDeepLink
data object Downloads : AppDeepLink
}
object AppDeepLinkRepository {
@ -49,6 +51,8 @@ fun buildMetaDeepLinkUrl(
append(id.trim().encodeURLParameter())
}
fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads"
private fun parseAppDeepLink(url: String): AppDeepLink? {
val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null
if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null
@ -60,6 +64,8 @@ private fun parseAppDeepLink(url: String): AppDeepLink? {
if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
"downloads" -> AppDeepLink.Downloads
else -> null
}
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.downloads
internal expect object DownloadsLiveStatusPlatform {
fun onItemsChanged(items: List<DownloadItem>)
}

View file

@ -32,6 +32,7 @@ object DownloadsRepository {
activeHandles.clear()
hasLoaded = false
_uiState.value = DownloadsUiState()
notifyLiveStatusPlatform()
}
fun findPlayableDownloadByVideoId(videoId: String?): DownloadItem? {
@ -225,6 +226,7 @@ object DownloadsRepository {
val payload = DownloadsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = DownloadsUiState()
notifyLiveStatusPlatform()
return
}
@ -242,6 +244,7 @@ object DownloadsRepository {
.sortedByDescending { it.updatedAtEpochMs }
_uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform()
}
private fun startDownload(item: DownloadItem) {
@ -331,6 +334,13 @@ object DownloadsRepository {
_uiState.value = DownloadsUiState(
items = items.sortedByDescending { it.updatedAtEpochMs },
)
notifyLiveStatusPlatform()
}
private fun notifyLiveStatusPlatform() {
runCatching {
DownloadsLiveStatusPlatform.onItemsChanged(_uiState.value.items)
}
}
private fun persist() {

View file

@ -0,0 +1,87 @@
package com.nuvio.app.features.downloads
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSUserDefaults
internal actual object DownloadsLiveStatusPlatform {
private const val notificationName = "NuvioDownloadsLiveStatusUpdated"
private const val userDefaultsPayloadKey = "nuvio.downloads.live_status.payload"
private val json = Json {
encodeDefaults = true
}
private var lastPayload: String? = null
actual fun onItemsChanged(items: List<DownloadItem>) {
val primary = items
.filter { item ->
item.status == DownloadStatus.Downloading ||
item.status == DownloadStatus.Paused ||
item.status == DownloadStatus.Failed
}
.sortedWith(
compareBy<DownloadItem> { statusPriority(it.status) }
.thenByDescending { it.updatedAtEpochMs },
)
.firstOrNull()
val payload = primary?.let { item ->
json.encodeToString(
DownloadsLiveStatusPayload(
id = item.id,
title = item.title,
subtitle = item.displaySubtitle,
posterUrl = item.episodeThumbnail
?.takeIf { it.isNotBlank() }
?: item.poster?.takeIf { it.isNotBlank() }
?: item.background?.takeIf { it.isNotBlank() },
status = item.status.name,
downloadedBytes = item.downloadedBytes,
totalBytes = item.totalBytes,
progressPercent = if (item.totalBytes != null && item.totalBytes > 0L) {
((item.downloadedBytes.toDouble() / item.totalBytes.toDouble()) * 100.0)
.toInt()
.coerceIn(0, 100)
} else {
-1
},
),
)
}
if (payload == lastPayload) return
lastPayload = payload
val defaults = NSUserDefaults.standardUserDefaults
if (payload == null) {
defaults.removeObjectForKey(userDefaultsPayloadKey)
} else {
defaults.setObject(payload, forKey = userDefaultsPayloadKey)
}
NSNotificationCenter.defaultCenter.postNotificationName(notificationName, null)
}
private fun statusPriority(status: DownloadStatus): Int = when (status) {
DownloadStatus.Downloading -> 0
DownloadStatus.Paused -> 1
DownloadStatus.Failed -> 2
DownloadStatus.Completed -> 3
}
}
@Serializable
private data class DownloadsLiveStatusPayload(
val id: String,
val title: String,
val subtitle: String,
val posterUrl: String? = null,
val status: String,
val downloadedBytes: Long,
val totalBytes: Long? = null,
val progressPercent: Int,
)

View file

@ -0,0 +1,220 @@
import ActivityKit
import SwiftUI
import WidgetKit
struct DownloadsLiveActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
let status: String
let progressPercent: Int
let transferredText: String
}
let downloadId: String
let title: String
let subtitle: String
let posterUrl: String?
}
@available(iOSApplicationExtension 16.1, *)
struct DownloadsLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DownloadsLiveActivityAttributes.self) { context in
DownloadActivityLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
PosterThumbnailView(urlString: context.attributes.posterUrl)
.frame(width: 44, height: 64)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 3) {
Text(progressLabel(context.state.progressPercent))
.font(.headline.monospacedDigit())
.foregroundStyle(.primary)
Text(statusLabel(context.state.status))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) {
Text(context.attributes.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(context.attributes.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
ProgressView(value: normalizedProgress(context.state.progressPercent))
.progressViewStyle(.linear)
HStack {
Text(context.state.transferredText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
Spacer(minLength: 6)
Text(statusLabel(context.state.status))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
} compactLeading: {
PosterGlyphView()
} compactTrailing: {
Text(progressLabel(context.state.progressPercent))
.font(.caption2.monospacedDigit())
} minimal: {
PosterGlyphView()
}
}
}
private func progressLabel(_ progressPercent: Int) -> String {
if progressPercent < 0 { return "--%" }
return "\(max(0, min(100, progressPercent)))%"
}
private func normalizedProgress(_ progressPercent: Int) -> Double {
guard progressPercent >= 0 else { return 0 }
return min(max(Double(progressPercent) / 100.0, 0), 1)
}
private func statusLabel(_ status: String) -> String {
switch status.lowercased() {
case "downloading": return "Downloading"
case "paused": return "Paused"
case "failed": return "Failed"
default: return "Active"
}
}
}
@available(iOSApplicationExtension 16.1, *)
private struct DownloadActivityLockScreenView: View {
let context: ActivityViewContext<DownloadsLiveActivityAttributes>
var body: some View {
HStack(alignment: .top, spacing: 12) {
PosterThumbnailView(urlString: context.attributes.posterUrl)
.frame(width: 62, height: 92)
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(context.attributes.title)
.font(.headline)
.lineLimit(1)
Spacer(minLength: 8)
Text(progressLabel(context.state.progressPercent))
.font(.headline.monospacedDigit())
}
Text(context.attributes.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
ProgressView(value: normalizedProgress(context.state.progressPercent))
.progressViewStyle(.linear)
HStack {
Text(statusLabel(context.state.status))
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(context.state.transferredText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 6)
.padding(.vertical, 6)
.activityBackgroundTint(Color(red: 0.10, green: 0.11, blue: 0.16).opacity(0.88))
.activitySystemActionForegroundColor(.white)
}
private func progressLabel(_ progressPercent: Int) -> String {
if progressPercent < 0 { return "--%" }
return "\(max(0, min(100, progressPercent)))%"
}
private func normalizedProgress(_ progressPercent: Int) -> Double {
guard progressPercent >= 0 else { return 0 }
return min(max(Double(progressPercent) / 100.0, 0), 1)
}
private func statusLabel(_ status: String) -> String {
switch status.lowercased() {
case "downloading": return "Downloading"
case "paused": return "Paused"
case "failed": return "Failed"
default: return "Active"
}
}
}
private struct PosterGlyphView: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.white.opacity(0.14))
Image(systemName: "film")
.font(.caption)
.foregroundStyle(.white.opacity(0.9))
}
.frame(width: 22, height: 22)
}
}
private struct PosterThumbnailView: View {
let urlString: String?
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(
LinearGradient(
colors: [Color(red: 0.16, green: 0.17, blue: 0.22), Color(red: 0.09, green: 0.10, blue: 0.14)],
startPoint: .topLeading,
endPoint: .bottomTrailing,
),
)
if let url = imageUrl {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
.tint(.white.opacity(0.85))
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
fallbackIcon
@unknown default:
fallbackIcon
}
}
} else {
fallbackIcon
}
}
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1),
)
.shadow(color: .black.opacity(0.24), radius: 6, x: 0, y: 3)
}
private var imageUrl: URL? {
guard let urlString, let url = URL(string: urlString), !urlString.isEmpty else {
return nil
}
return url
}
private var fallbackIcon: some View {
Image(systemName: "film")
.font(.title3)
.foregroundStyle(.white.opacity(0.82))
}
}

View file

@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct DownloadsWidgetBundle: WidgetBundle {
var body: some Widget {
DownloadsLiveActivityWidget()
}
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Downloads</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>MinimumOSVersion</key>
<string>$(IPHONEOS_DEPLOYMENT_TARGET)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -7,14 +7,47 @@
objects = {
/* Begin PBXBuildFile section */
0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */; };
7397CF462F80FE3A00AC0F84 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* MPVKit */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E819B502921EC7C68AC4965A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0A1B2C3D4E5F60718293A4B8;
remoteInfo = DownloadsWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DownloadsWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
07921B4E8F8546BEE682C2FE /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */;
};
77B823F8A18A09B45EA3C7D4 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@ -25,6 +58,14 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */,
);
path = DownloadsWidgetExtension;
sourceTree = "<group>";
};
69649F6DF5D3AF53A24CA9C3 /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
@ -41,6 +82,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
0A1B2C3D4E5F60718293A4BA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
B8D8368DBB6E1F38F403E5AA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -57,6 +105,7 @@
children = (
69649F6DF5D3AF53A24CA9C3 /* Configuration */,
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */,
AD93033A33973C81BB77B73A /* Products */,
);
sourceTree = "<group>";
@ -65,6 +114,7 @@
isa = PBXGroup;
children = (
07921B4E8F8546BEE682C2FE /* Nuvio.app */,
0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -72,6 +122,28 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */;
buildPhases = (
0A1B2C3D4E5F60718293A4B9 /* Sources */,
0A1B2C3D4E5F60718293A4BA /* Frameworks */,
0A1B2C3D4E5F60718293A4BB /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */,
);
name = DownloadsWidgetExtension;
packageProductDependencies = (
);
productName = DownloadsWidgetExtension;
productReference = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
E1B229BC363ABB711AF255E3 /* iosApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = E4075507B4A6F771FEF44E6F /* Build configuration list for PBXNativeTarget "iosApp" */;
@ -79,11 +151,13 @@
8C96BAEBBE1F4269A0BADC2B /* Compile Kotlin Framework */,
79E42700D221AF24510E3E09 /* Sources */,
B8D8368DBB6E1F38F403E5AA /* Frameworks */,
0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */,
2DAFDB313E32F227766A2072 /* Resources */,
);
buildRules = (
);
dependencies = (
0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A2F97F59D21EB4D4B662C8D1 /* iosApp */,
@ -106,6 +180,9 @@
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
0A1B2C3D4E5F60718293A4B8 = {
CreatedOnToolsVersion = 16.2;
};
E1B229BC363ABB711AF255E3 = {
CreatedOnToolsVersion = 16.2;
};
@ -128,12 +205,20 @@
projectDirPath = "";
projectRoot = "";
targets = (
0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */,
E1B229BC363ABB711AF255E3 /* iosApp */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0A1B2C3D4E5F60718293A4BB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
2DAFDB313E32F227766A2072 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -166,6 +251,13 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0A1B2C3D4E5F60718293A4B9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
79E42700D221AF24510E3E09 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -175,7 +267,63 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */;
targetProxy = 0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
0A1B2C3D4E5F60718293A4BF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 8QBDZ766S3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
0A1B2C3D4E5F60718293A4C0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 8QBDZ766S3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
08C6158BC74ED5D18954BFD9 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 69649F6DF5D3AF53A24CA9C3 /* Configuration */;
@ -358,6 +506,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0A1B2C3D4E5F60718293A4BF /* Debug */,
0A1B2C3D4E5F60718293A4C0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BC8D3A48007CFD75F9C7AB47 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View file

@ -0,0 +1,132 @@
import Foundation
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
import ActivityKit
#endif
private let downloadsLiveStatusUpdatedNotification = Notification.Name("NuvioDownloadsLiveStatusUpdated")
private let downloadsLiveStatusPayloadKey = "nuvio.downloads.live_status.payload"
final class DownloadsLiveActivityManager {
static let shared = DownloadsLiveActivityManager()
private var observer: NSObjectProtocol?
private init() {}
func start() {
guard observer == nil else { return }
observer = NotificationCenter.default.addObserver(
forName: downloadsLiveStatusUpdatedNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.syncFromPayloadStore()
}
syncFromPayloadStore()
}
private func syncFromPayloadStore() {
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
guard #available(iOS 16.1, *) else { return }
let payload = loadPayload()
Task {
await apply(payload)
}
#endif
}
private func loadPayload() -> DownloadsLiveStatusPayload? {
guard let encoded = UserDefaults.standard.string(forKey: downloadsLiveStatusPayloadKey) else {
return nil
}
let data = Data(encoded.utf8)
return try? JSONDecoder().decode(DownloadsLiveStatusPayload.self, from: data)
}
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
@available(iOS 16.1, *)
private func apply(_ payload: DownloadsLiveStatusPayload?) async {
let existing = Activity<DownloadsLiveActivityAttributes>.activities.first
guard let payload else {
if let existing {
await existing.end(dismissalPolicy: .immediate)
}
return
}
let state = DownloadsLiveActivityAttributes.ContentState(
status: payload.status,
progressPercent: payload.progressPercent,
transferredText: transferredText(payload)
)
if let existing, existing.attributes.downloadId == payload.id {
await existing.update(using: state)
return
}
if let existing {
await existing.end(dismissalPolicy: .immediate)
}
let attributes = DownloadsLiveActivityAttributes(
downloadId: payload.id,
title: payload.title,
subtitle: payload.subtitle,
posterUrl: payload.posterUrl
)
_ = try? Activity<DownloadsLiveActivityAttributes>.request(
attributes: attributes,
contentState: state,
pushType: nil
)
}
#endif
private func transferredText(_ payload: DownloadsLiveStatusPayload) -> String {
let downloaded = formatBytes(payload.downloadedBytes)
if let total = payload.totalBytes {
return "\(downloaded) / \(formatBytes(total))"
}
return downloaded
}
private func formatBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst)
@available(iOS 16.1, *)
struct DownloadsLiveActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
let status: String
let progressPercent: Int
let transferredText: String
}
let downloadId: String
let title: String
let subtitle: String
let posterUrl: String?
}
#endif
private struct DownloadsLiveStatusPayload: Decodable {
let id: String
let title: String
let subtitle: String
let posterUrl: String?
let status: String
let downloadedBytes: Int64
let totalBytes: Int64?
let progressPercent: Int
}

View file

@ -17,5 +17,7 @@
</array>
</dict>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View file

@ -11,6 +11,7 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
OrientationLockCoordinator.shared.start()
DownloadsLiveActivityManager.shared.start()
UNUserNotificationCenter.current().delegate = self
return true
}