diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml index 9a7ccf90..1e2a3f3d 100644 --- a/.github/workflows/android-beta.yml +++ b/.github/workflows/android-beta.yml @@ -46,29 +46,34 @@ env: jobs: release: - runs-on: ubuntu-latest + runs-on: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON('["self-hosted","stack-android"]') || 'ubuntu-latest' }} timeout-minutes: 50 - permissions: - contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@v6.0.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6.4.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-disabled: ${{ runner.environment == 'self-hosted' }} - name: Install Android SDK platform + if: ${{ runner.environment == 'github-hosted' }} run: | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \ "platforms;android-37.0" "build-tools;36.0.0" > /dev/null - name: Validate beta release version + id: version env: GH_TOKEN: ${{ github.token }} run: | @@ -103,8 +108,13 @@ jobs: echo "RELEASE_CODE=${version_code}" echo "RELEASE_TAG=${release_tag}" } >> "$GITHUB_ENV" + { + echo "version=${version_name}" + echo "tag=${release_tag}" + } >> "$GITHUB_OUTPUT" - name: Prepare signing + if: ${{ github.event_name != 'pull_request' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} @@ -135,7 +145,7 @@ jobs: - name: Upload lint report if: ${{ always() }} - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typetype-beta-lint-report path: | @@ -153,7 +163,9 @@ jobs: test -n "$build_tools" "$build_tools/zipalign" -c -P 16 -v 4 "$apk" - "$build_tools/apksigner" verify --verbose --print-certs "$apk" + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + "$build_tools/apksigner" verify --verbose --print-certs "$apk" + fi apk_application_id="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest application-id "$apk")" apk_version="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-name "$apk")" @@ -185,7 +197,7 @@ jobs: > artifacts/typetype-update.json - name: Upload beta APK - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typetype-beta-apk path: | @@ -196,8 +208,27 @@ jobs: compression-level: 0 retention-days: 30 + publish: + needs: release + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + env: + RELEASE_VERSION: ${{ needs.release.outputs.version }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download beta APK + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: typetype-beta-apk + path: artifacts + - name: Prepare beta release notes - if: ${{ github.event_name != 'pull_request' }} env: HEAD_MESSAGE: ${{ github.event.head_commit.message }} run: | @@ -208,8 +239,7 @@ jobs: fi - name: Publish GitHub prerelease - if: ${{ github.event_name != 'pull_request' }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ env.RELEASE_TAG }} name: TypeType Android ${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0ed472de..915eba15 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,11 +41,11 @@ env: jobs: verify: if: ${{ github.event_name != 'workflow_dispatch' || !inputs.publish_release || github.ref != 'refs/heads/main' }} - runs-on: ubuntu-latest + runs-on: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted","stack-android"]') || 'ubuntu-latest' }} timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check file length run: | @@ -63,15 +63,18 @@ jobs: exit "$status" - name: Set up Java - uses: actions/setup-java@v6.0.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6.4.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-disabled: ${{ runner.environment == 'self-hosted' }} - name: Install Android SDK platform + if: ${{ runner.environment == 'github-hosted' }} run: | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \ "platforms;android-37.0" "build-tools;36.0.0" > /dev/null @@ -90,7 +93,7 @@ jobs: ./gradlew --stacktrace "${tasks[@]}" - name: Upload debug APK - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typetype-debug-apk path: | @@ -101,7 +104,7 @@ jobs: - name: Upload lint report if: ${{ always() }} - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: lint-report path: | @@ -117,16 +120,16 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@v6.0.1 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 21 - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6.4.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Install Android SDK platform run: | @@ -182,7 +185,7 @@ jobs: - name: Upload lint report if: ${{ always() }} - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: lint-report path: | @@ -224,7 +227,7 @@ jobs: ) - name: Upload release APK - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: typetype-release-apk path: | @@ -236,7 +239,7 @@ jobs: retention-days: 30 - name: Publish GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ env.RELEASE_TAG }} name: ${{ env.RELEASE_TAG }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b5b741d..e927fca3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11104 - versionName = "1.11.0-beta.4" + versionCode = 11105 + versionName = "1.11.0-beta.5" } signingConfigs { @@ -79,7 +79,6 @@ android { } androidComponents.onVariants { variant -> - // Benchmarks and profile generation install their own package, so they never replace the user's app. if (variant.name == "benchmarkRelease" || variant.name == "nonMinifiedRelease") { variant.applicationId.set("dev.typetype.android.benchmark") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 005030ae..fceb70e7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,12 +1,6 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. - -# Keep line numbers for stack traces in production crash reports. -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile -# kotlinx.serialization -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { static <1>$Companion Companion; diff --git a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt index 05d29b83..7031a0a8 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsPlaybackContinuityAndroidTest.kt @@ -74,8 +74,6 @@ class ShortsPlaybackContinuityAndroidTest { instrumentation.runOnMainSync { controller.stop() controller.clearMediaItems() - // The surface dies with this activity; a released controller could no longer detach it from the - // shared service player, and the next test would decode into it. controller.clearVideoSurface() controller.release() } diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index 90cad85c..0cb0a969 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -21,6 +21,7 @@ import dev.typetype.android.feature.settings.SettingsRoute as SettingsRouteScree import dev.typetype.android.feature.settings.about.LicensesScreen import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceScreen import dev.typetype.android.feature.settings.imports.PortabilityRoute as ImportDataRouteScreen +import dev.typetype.android.feature.settings.notifications.ChannelNotificationsRoute as ChannelNotificationsRouteScreen import dev.typetype.android.feature.settings.notifications.PushNotificationsRoute as PushNotificationsRouteScreen import dev.typetype.android.feature.settings.rss.RssFeedsRoute as RssFeedsRouteScreen @@ -64,6 +65,12 @@ internal fun NavGraphBuilder.settingsDestinations( composable { PushNotificationsRouteScreen( onNavigateBack = { navController.popBackStack() }, + onOpenChannels = { navController.navigate(dev.typetype.android.core.ui.navigation.ChannelNotificationsRoute) }, + ) + } + composable { + ChannelNotificationsRouteScreen( + onNavigateBack = { navController.popBackStack() }, ) } composable { diff --git a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt index bf1237c5..eee999af 100644 --- a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt +++ b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt @@ -11,8 +11,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -// Runs on a device where the benchmark build is already signed in to a test instance: the journeys need a home -// feed, Shorts and a playable video, which a fresh install does not have. @RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule diff --git a/build-logic/convention/src/main/kotlin/AndroidDefaults.kt b/build-logic/convention/src/main/kotlin/AndroidDefaults.kt index cce5804a..3bcc3f1e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidDefaults.kt +++ b/build-logic/convention/src/main/kotlin/AndroidDefaults.kt @@ -8,7 +8,6 @@ import org.gradle.kotlin.dsl.getByType internal const val COMPILE_SDK = 37 internal const val MIN_SDK = 23 -// java.time and other newer APIs are desugared for API 23, in every module that uses them. internal fun CommonExtension.configureAndroidDefaults(project: Project) { compileSdk = COMPILE_SDK defaultConfig.minSdk = MIN_SDK @@ -19,9 +18,7 @@ internal fun CommonExtension.configureAndroidDefaults(project: Project) { lint.abortOnError = true lint.checkReleaseBuilds = false lint.warningsAsErrors = true - // Newer versions appear on their own schedule; a build should not start failing because a day passed. lint.disable += setOf("GradleDependency", "NewerVersionAvailable", "AndroidGradlePluginVersion") - // Verifying the TypeType links needs an assetlinks file on the domain, which is a release decision. lint.informational += "AppLinkWarning" val libs = project.extensions.getByType().named("libs") project.dependencies { diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 8fb760e6..ff2a78cf 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -5,7 +5,6 @@ import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.project -// A screen family: Compose and Hilt, the shared core modules, and the libraries every screen uses. class AndroidFeatureConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("typetype.android.compose") diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 590e7bf3..4f630eef 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -8,7 +8,6 @@ android { namespace = "dev.typetype.android.core.data" } -// Migration tests read the exported schemas from the test APK assets. androidComponents.onVariants { variant -> variant.androidTest?.sources?.assets?.addStaticSourceDirectory("$projectDir/schemas") } diff --git a/core/data/src/androidTest/AndroidManifest.xml b/core/data/src/androidTest/AndroidManifest.xml index 35aaec37..36f3d2cf 100644 --- a/core/data/src/androidTest/AndroidManifest.xml +++ b/core/data/src/androidTest/AndroidManifest.xml @@ -1,5 +1,4 @@ - diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/NotificationDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/NotificationDto.kt index c7ac445b..1785a636 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/dto/NotificationDto.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/NotificationDto.kt @@ -2,6 +2,7 @@ package dev.typetype.android.data.network.dto import dev.typetype.android.domain.notifications.NotificationItem import dev.typetype.android.domain.notifications.NotificationsPage +import dev.typetype.android.domain.notifications.UNKNOWN_SERVICE_ID import kotlinx.serialization.Serializable @Serializable @@ -14,6 +15,7 @@ data class NotificationItemDto( val channelName: String, val channelAvatarUrl: String, val video: VideoItem, + val serviceId: Int = UNKNOWN_SERVICE_ID, ) @Serializable @@ -53,4 +55,5 @@ private fun NotificationItemDto.toDomain(): NotificationItem = NotificationItem( channelName = channelName, channelAvatarUrl = channelAvatarUrl, video = video.toDomainVideo(), + serviceId = serviceId, ) diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt index 1adf1679..d38e2e2c 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -53,4 +53,5 @@ internal fun ChannelNotificationPreferenceDto.toDomain(): ChannelNotificationsPr ChannelNotificationsPreference( channelUrl = channelUrl, enabled = enabled, + updatedAtMillis = updatedAt, ) diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt index 553bf3c8..6b050fc5 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt @@ -136,8 +136,6 @@ internal class SabrPlaybackSessionPreparer( } } - // The server can leave a session waiting on a segment that never comes, while a new session at the same position - // plays at once. Replacing it here keeps the player's buffer and media item; a second stall is reported. private suspend fun replacingStuckSession( api: TypeTypeMediaApi, baseUrl: String, diff --git a/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt b/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt index 493b4015..158e972c 100644 --- a/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt +++ b/core/data/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt @@ -56,8 +56,6 @@ internal suspend fun transientPlaybackRequest( } } -// Covers a gateway blip of about ten seconds. Longer outages during playback are retried by SabrWindowRecovery, so -// a video that cannot open fails within seconds instead of a minute of skeleton. private const val MAX_SERVER_RESPONSE_RETRIES = 6 private const val MAX_CONNECTED_TRANSPORT_RETRIES = 8 private const val MAX_OFFLINE_WAIT_MS = 10_000L diff --git a/core/data/src/test/java/dev/typetype/android/data/stream/SabrServerScript.kt b/core/data/src/test/java/dev/typetype/android/data/stream/SabrServerScript.kt index c0240c76..4eaea6a4 100644 --- a/core/data/src/test/java/dev/typetype/android/data/stream/SabrServerScript.kt +++ b/core/data/src/test/java/dev/typetype/android/data/stream/SabrServerScript.kt @@ -11,7 +11,6 @@ import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -// Queues the control responses of a TypeType server for one SABR video, in the order the client asks for them. internal class SabrServerScript { val server = MockWebServer().apply { start() } val baseUrl: String = server.url("/api/").toString() @@ -61,7 +60,6 @@ internal class SabrServerScript { ) } - // A pending prefetch keeps the same buffered edge, so repeating it is what the client reads as a stuck session. fun enqueuePrefetch(ready: Boolean, sessionId: String = "session", generation: Long = 0) { val retry = if (ready) "null" else "250" val status = if (ready) "ready" else "preparing" diff --git a/core/model/src/main/java/dev/typetype/android/core/error/SuspendRunCatching.kt b/core/model/src/main/java/dev/typetype/android/core/error/SuspendRunCatching.kt index 06642ee1..d9e3cfb5 100644 --- a/core/model/src/main/java/dev/typetype/android/core/error/SuspendRunCatching.kt +++ b/core/model/src/main/java/dev/typetype/android/core/error/SuspendRunCatching.kt @@ -2,8 +2,6 @@ package dev.typetype.android.core.error import kotlin.coroutines.cancellation.CancellationException -// runCatching for suspending work: a cancelled coroutine stays cancelled instead of turning into a failed Result, -// so a closed screen or a replaced request does not report an error or keep running. inline fun suspendRunCatching(block: () -> T): Result = try { Result.success(block()) } catch (cancelled: CancellationException) { diff --git a/core/model/src/main/java/dev/typetype/android/domain/auth/OidcRedirect.kt b/core/model/src/main/java/dev/typetype/android/domain/auth/OidcRedirect.kt index dd3d1997..6c9ed0dc 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/auth/OidcRedirect.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/auth/OidcRedirect.kt @@ -2,7 +2,6 @@ package dev.typetype.android.domain.auth import java.net.URI -// The app's own scheme receives the sign-in callback, so it follows the installed package (".debug" included). class OidcRedirect(val scheme: String) { val uri: String = "$scheme://oidc/callback" diff --git a/core/model/src/main/java/dev/typetype/android/domain/notifications/NotificationModels.kt b/core/model/src/main/java/dev/typetype/android/domain/notifications/NotificationModels.kt index bf54ea14..5a6f33d0 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/notifications/NotificationModels.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/notifications/NotificationModels.kt @@ -11,8 +11,11 @@ data class NotificationItem( val channelName: String, val channelAvatarUrl: String, val video: Video, + val serviceId: Int = UNKNOWN_SERVICE_ID, ) +const val UNKNOWN_SERVICE_ID = -1 + data class NotificationsPage( val items: List, val unreadCount: Int, diff --git a/core/model/src/main/java/dev/typetype/android/domain/preferences/VideoCodecKeys.kt b/core/model/src/main/java/dev/typetype/android/domain/preferences/VideoCodecKeys.kt index 9d65b190..edba32b7 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/preferences/VideoCodecKeys.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/preferences/VideoCodecKeys.kt @@ -1,6 +1,5 @@ package dev.typetype.android.domain.preferences -// Stored values of the preferred video codec setting. const val RECOMMENDED_CODEC_KEY = "recommended" const val H264_CODEC_KEY = "h264" const val HEVC_CODEC_KEY = "hevc" diff --git a/core/model/src/main/java/dev/typetype/android/domain/push/PushModels.kt b/core/model/src/main/java/dev/typetype/android/domain/push/PushModels.kt index 38c2e172..47892372 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/push/PushModels.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/push/PushModels.kt @@ -8,6 +8,7 @@ data class PushDevice( data class ChannelNotificationsPreference( val channelUrl: String, val enabled: Boolean, + val updatedAtMillis: Long = 0L, ) fun ChannelNotificationsPreference.matchesChannel(channelUrl: String): Boolean = diff --git a/core/model/src/main/java/dev/typetype/android/domain/version/AppVersion.kt b/core/model/src/main/java/dev/typetype/android/domain/version/AppVersion.kt index 1e0cd021..2a619ed0 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/version/AppVersion.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/version/AppVersion.kt @@ -1,4 +1,3 @@ package dev.typetype.android.domain.version -// The installed build, as reported to the server and in bug reports. data class AppVersion(val name: String, val code: Long) diff --git a/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationDecision.kt b/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationDecision.kt index ffcda6f6..d3006145 100644 --- a/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationDecision.kt +++ b/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationDecision.kt @@ -14,6 +14,7 @@ internal fun decideLocalNotifications( knownKeys: Set, seeded: Boolean, enabledChannels: List, + nowMillis: Long, ): LocalNotificationDecision { val fresh = items.filter { item -> item.localNotificationKey() !in knownKeys } if (!seeded) { @@ -25,16 +26,33 @@ internal fun decideLocalNotifications( if (fresh.isEmpty()) return LocalNotificationDecision(emptyList(), emptyList()) val deliverableChannels = enabledChannels.filter { it.enabled } val deliverable = fresh - .filter { item -> deliverableChannels.any { it.matchesChannel(item.channelUrl) } } + .filter { item -> item.isAlertWorthy(nowMillis) } + .filter { item -> + deliverableChannels.any { bell -> + bell.matchesChannel(item.channelUrl) && item.publishedAtMillis >= bell.updatedAtMillis + } + } .asReversed() return LocalNotificationDecision( - keysToRecord = fresh.map { item -> item.localNotificationKey() }, + keysToRecord = fresh + .filterNot { item -> item.publishedAtMillis > nowMillis + CLOCK_SKEW_MILLIS } + .map { item -> item.localNotificationKey() }, notified = deliverable, ) } +private fun NotificationItem.isAlertWorthy(nowMillis: Long): Boolean { + val video = video + if (video.requiresMembership || video.isShortFormContent) return false + if (video.isLive || video.isLiveContent || video.isPostLive) return false + return publishedAtMillis in (nowMillis - ALERT_WINDOW_MILLIS)..(nowMillis + CLOCK_SKEW_MILLIS) +} + internal fun NotificationItem.localNotificationKey(): String = video.id.takeIf { it.isNotBlank() } ?: video.url internal fun LocalNotificationDecision.keysToPersist(unpostedKeys: Set): List = keysToRecord.filterNot(unpostedKeys::contains) + +private const val ALERT_WINDOW_MILLIS = 24L * 60 * 60 * 1000 +private const val CLOCK_SKEW_MILLIS = 5L * 60 * 1000 diff --git a/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationPoller.kt b/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationPoller.kt index 563ddb81..13bfe6cc 100644 --- a/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationPoller.kt +++ b/core/notifications/src/main/java/dev/typetype/android/services/notifications/LocalNotificationPoller.kt @@ -42,56 +42,60 @@ class LocalNotificationPoller @Inject constructor( knownKeys = state.keys.toHashSet(), seeded = state.seeded, enabledChannels = enabledChannels, + nowMillis = System.currentTimeMillis(), ) - if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) return 0 - val unpostedKeys = mutableSetOf() - var postedCount = 0 - decision.notified.forEach { item -> - when (notifier.notifySubscription(item)) { - NotificationPostResult.Posted -> { - postedCount += 1 - pushDiagnostics.record(scope, PushDiagnosticEvent.LocalNotificationPosted) - } - NotificationPostResult.NotificationsDisabled -> { - unpostedKeys += item.localNotificationKey() - pushDiagnostics.record( - scope, - PushDiagnosticEvent.LocalNotificationSuppressed, - failureCode = "notifications_disabled", - ) - } - NotificationPostResult.ChannelBlocked -> { - unpostedKeys += item.localNotificationKey() - pushDiagnostics.record( - scope, - PushDiagnosticEvent.LocalNotificationSuppressed, - failureCode = "channel_blocked", - ) - } - NotificationPostResult.PermissionDenied -> { - unpostedKeys += item.localNotificationKey() - pushDiagnostics.record( - scope, - PushDiagnosticEvent.LocalNotificationSuppressed, - failureCode = "permission_denied", - ) - } - NotificationPostResult.Failed -> { - unpostedKeys += item.localNotificationKey() - pushDiagnostics.record( - scope, - PushDiagnosticEvent.LocalNotificationFailed, - failureCode = "notification_manager_unavailable", - ) - } - } + if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) { + if (!state.seeded) seenStore.record(scope, emptyList(), seeded = true) + return 0 } + val outcomes = if (decision.notified.size > MAX_SEPARATE_ALERTS) { + val result = notifier.notifySummary(decision.notified) + decision.notified.map { item -> item to result } + } else { + decision.notified.map { item -> item to notifier.notifySubscription(item) } + } + val unpostedKeys = outcomes + .filter { (_, result) -> result != NotificationPostResult.Posted } + .map { (item, _) -> item.localNotificationKey() } + .toSet() + outcomes.forEach { (_, result) -> recordOutcome(scope, result) } seenStore.record( scope, decision.keysToPersist(unpostedKeys), seeded = true, ) - return postedCount + return outcomes.count { (_, result) -> result == NotificationPostResult.Posted } + } + + private suspend fun recordOutcome(scope: AccountScope, result: NotificationPostResult) { + when (result) { + NotificationPostResult.Posted -> + pushDiagnostics.record(scope, PushDiagnosticEvent.LocalNotificationPosted) + NotificationPostResult.NotificationsDisabled -> + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "notifications_disabled", + ) + NotificationPostResult.ChannelBlocked -> + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "channel_blocked", + ) + NotificationPostResult.PermissionDenied -> + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationSuppressed, + failureCode = "permission_denied", + ) + NotificationPostResult.Failed -> + pushDiagnostics.record( + scope, + PushDiagnosticEvent.LocalNotificationFailed, + failureCode = "notification_manager_unavailable", + ) + } } private suspend fun eligibleScope(): AccountScope { @@ -102,4 +106,8 @@ class LocalNotificationPoller @Inject constructor( } return scope } + + private companion object { + const val MAX_SEPARATE_ALERTS = 3 + } } diff --git a/core/notifications/src/main/java/dev/typetype/android/services/push/NotificationPlatform.kt b/core/notifications/src/main/java/dev/typetype/android/services/push/NotificationPlatform.kt new file mode 100644 index 00000000..ff923218 --- /dev/null +++ b/core/notifications/src/main/java/dev/typetype/android/services/push/NotificationPlatform.kt @@ -0,0 +1,43 @@ +package dev.typetype.android.services.push + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import dev.typetype.android.R + +internal enum class NotificationPlatform( + val serviceId: Int, + @param:StringRes val nameRes: Int, + @param:DrawableRes val iconRes: Int, + @param:ColorInt val color: Int, +) { + YouTube(0, R.string.settings_default_service_youtube, R.drawable.ic_service_youtube, 0xFFFF0000.toInt()), + BiliBili(5, R.string.settings_default_service_bilibili, R.drawable.ic_service_bilibili, 0xFF00A1D6.toInt()), + NicoNico(6, R.string.settings_default_service_niconico, R.drawable.ic_service_niconico, 0xFF231815.toInt()), + ; + + companion object { + fun of(serviceId: Int): NotificationPlatform? = entries.firstOrNull { it.serviceId == serviceId } + } +} + +internal fun NotificationPlatform.badge(context: Context): Bitmap? { + val glyph = ContextCompat.getDrawable(context, iconRes)?.mutate() ?: return null + val size = context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width) + val bitmap = createBitmap(size, size) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = this@badge.color } + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + val inset = size / 4 + glyph.setTint(Color.WHITE) + glyph.setBounds(inset, inset, size - inset, size - inset) + glyph.draw(canvas) + return bitmap +} diff --git a/core/notifications/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/core/notifications/src/main/java/dev/typetype/android/services/push/PushNotifier.kt index 37f2bf7f..5d0f2de2 100644 --- a/core/notifications/src/main/java/dev/typetype/android/services/push/PushNotifier.kt +++ b/core/notifications/src/main/java/dev/typetype/android/services/push/PushNotifier.kt @@ -32,34 +32,43 @@ class PushNotifier @Inject constructor( else -> null } - fun notify(payload: PushPayload): NotificationPostResult = show( - title = payload.channelName.ifBlank { payload.serviceName }, - text = payload.title, - videoUrl = payload.videoUrl, - notificationId = subscriptionNotificationId( + fun notify(payload: PushPayload): NotificationPostResult = post( + id = subscriptionNotificationId( videoId = payload.videoId, videoUrl = payload.videoUrl, fallback = payload.eventId, ), - ) + ) { id -> + videoNotification( + videoTitle = payload.title, + channelName = payload.channelName, + serviceId = payload.serviceId, + videoUrl = payload.videoUrl, + id = id, + ) + } - fun notifySubscription(item: NotificationItem): NotificationPostResult = show( - title = item.channelName.ifBlank { item.video.uploaderName }, - text = item.title, - videoUrl = item.video.url, - notificationId = subscriptionNotificationId( + fun notifySubscription(item: NotificationItem): NotificationPostResult = post( + id = subscriptionNotificationId( videoId = item.video.id, videoUrl = item.video.url, fallback = item.video.url, ), - ) + ) { id -> + videoNotification( + videoTitle = item.video.title.ifBlank { item.title }, + channelName = item.channelName.ifBlank { item.video.uploaderName }, + serviceId = item.serviceId, + videoUrl = item.video.url, + id = id, + ) + } - private fun show( - title: String, - text: String, - videoUrl: String, - notificationId: Int, - ): NotificationPostResult { + fun notifySummary(items: List): NotificationPostResult = post(SUMMARY_ID) { id -> + summaryNotification(items, id) + } + + private fun post(id: Int, build: (Int) -> Notification): NotificationPostResult { val manager = context.getSystemService(NotificationManager::class.java) ?: return NotificationPostResult.Failed if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -67,7 +76,7 @@ class PushNotifier @Inject constructor( } blockedReason()?.let { return it } return try { - manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) + manager.notify(id, build(id)) NotificationPostResult.Posted } catch (_: SecurityException) { NotificationPostResult.PermissionDenied @@ -80,32 +89,57 @@ class PushNotifier @Inject constructor( return manager.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE } - private fun buildNotification( - title: String, - text: String, + private fun videoNotification( + videoTitle: String, + channelName: String, + serviceId: Int, videoUrl: String, - notificationId: Int, + id: Int, ): Notification { val deepLink = Intent( Intent.ACTION_VIEW, "typetype://watch?v=${toPublicWatchParameter(videoUrl)}".toUri(), ) - val contentIntent = PendingIntent.getActivity( - context, - notificationId, - deepLink, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_monochrome) + val platform = NotificationPlatform.of(serviceId) + return baseBuilder(PendingIntent.getActivity(context, id, deepLink, PENDING_FLAGS)) + .setContentTitle(videoTitle) + .setContentText(channelName) + .setStyle(NotificationCompat.BigTextStyle().bigText(videoTitle).setSummaryText(channelName)) + .apply { + if (platform != null) { + setSubText(context.getString(platform.nameRes)) + setColor(platform.color) + setLargeIcon(platform.badge(context)) + } + } + .build() + } + + private fun summaryNotification(items: List, id: Int): Notification { + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent() + val title = context.resources.getQuantityString(R.plurals.push_summary_title, items.size, items.size) + val style = NotificationCompat.InboxStyle().setBigContentTitle(title) + items.take(SUMMARY_LINES).forEach { item -> + style.addLine("${item.channelName.ifBlank { item.video.uploaderName }} · ${item.video.title}") + } + if (items.size > SUMMARY_LINES) { + val more = items.size - SUMMARY_LINES + style.setSummaryText(context.resources.getQuantityString(R.plurals.push_summary_more, more, more)) + } + return baseBuilder(PendingIntent.getActivity(context, id, launch, PENDING_FLAGS)) .setContentTitle(title) - .setContentText(text) - .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentText(items.map { it.channelName }.distinct().joinToString(", ")) + .setStyle(style) + .setNumber(items.size) + .build() + } + + private fun baseBuilder(contentIntent: PendingIntent): NotificationCompat.Builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) .setContentIntent(contentIntent) .setAutoCancel(true) .setOnlyAlertOnce(true) - .build() - } @RequiresApi(Build.VERSION_CODES.O) private fun notificationChannel(): NotificationChannel = NotificationChannel( @@ -116,6 +150,9 @@ class PushNotifier @Inject constructor( private companion object { const val CHANNEL_ID = "subscription_push" + const val SUMMARY_ID = 0x7454 + const val SUMMARY_LINES = 5 + const val PENDING_FLAGS = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } } diff --git a/core/notifications/src/test/java/dev/typetype/android/services/notifications/LocalNotificationDecisionTest.kt b/core/notifications/src/test/java/dev/typetype/android/services/notifications/LocalNotificationDecisionTest.kt index dfaf28f9..7d68a050 100644 --- a/core/notifications/src/test/java/dev/typetype/android/services/notifications/LocalNotificationDecisionTest.kt +++ b/core/notifications/src/test/java/dev/typetype/android/services/notifications/LocalNotificationDecisionTest.kt @@ -15,6 +15,7 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = false, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) assertEquals(listOf("a", "b"), decision.keysToRecord) @@ -32,6 +33,7 @@ class LocalNotificationDecisionTest { knownKeys = setOf("seen"), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) assertEquals(listOf("new-enabled", "new-silent"), decision.keysToRecord) @@ -45,6 +47,7 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = false)), + nowMillis = NOW, ) assertEquals(listOf("a"), decision.keysToRecord) @@ -58,12 +61,14 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) val trailingOnPreference = decideLocalNotifications( items = listOf(item(videoId = "a", channelUrl = CHANNEL)), knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference("$CHANNEL/videos", enabled = true)), + nowMillis = NOW, ) assertEquals(listOf("a"), trailingOnItem.notified.map { it.video.id }) @@ -77,6 +82,7 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) assertTrue(decision.notified.isEmpty()) @@ -92,6 +98,7 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id }) @@ -108,6 +115,7 @@ class LocalNotificationDecisionTest { knownKeys = emptySet(), seeded = true, enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + nowMillis = NOW, ) assertEquals(listOf("posted", "silent"), decision.keysToPersist(setOf("failed"))) @@ -140,6 +148,7 @@ class LocalNotificationDecisionTest { ) private companion object { + const val NOW = 1_000L const val CHANNEL = "https://www.youtube.com/channel/UCaaaaaaaaaaaaaaaaaaaaaa" const val OTHER_CHANNEL = "https://www.youtube.com/channel/UCbbbbbbbbbbbbbbbbbbbbbb" } diff --git a/core/playback/src/androidTest/AndroidManifest.xml b/core/playback/src/androidTest/AndroidManifest.xml index 35aaec37..36f3d2cf 100644 --- a/core/playback/src/androidTest/AndroidManifest.xml +++ b/core/playback/src/androidTest/AndroidManifest.xml @@ -1,5 +1,4 @@ - diff --git a/core/playback/src/main/java/dev/typetype/android/playback/PlaybackCodecSupport.kt b/core/playback/src/main/java/dev/typetype/android/playback/PlaybackCodecSupport.kt index cd465b31..e6e850a7 100644 --- a/core/playback/src/main/java/dev/typetype/android/playback/PlaybackCodecSupport.kt +++ b/core/playback/src/main/java/dev/typetype/android/playback/PlaybackCodecSupport.kt @@ -23,12 +23,9 @@ interface PlaybackCodecSupport { fun video(source: StreamVideoSource): DecoderSupport fun audio(source: StreamAudioSource): DecoderSupport - // lasting keeps the rejection across launches; without it the format is avoided until the app restarts. fun rejectVideo(format: Format, lasting: Boolean): Boolean = false } -// One instance for the whole app: the player, Shorts, prewarming and the queue share what each decoder can play -// and which formats already failed. @OptIn(markerClass = [UnstableApi::class]) @Singleton class DevicePlaybackCodecSupport @Inject constructor( diff --git a/core/playback/src/main/java/dev/typetype/android/playback/RejectedVideoFormatStore.kt b/core/playback/src/main/java/dev/typetype/android/playback/RejectedVideoFormatStore.kt index d1c22662..e6807525 100644 --- a/core/playback/src/main/java/dev/typetype/android/playback/RejectedVideoFormatStore.kt +++ b/core/playback/src/main/java/dev/typetype/android/playback/RejectedVideoFormatStore.kt @@ -7,8 +7,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton -// Video formats a decoder failed on, kept across launches so "Smart" does not pick them again and fail the same -// way each time. A system update can bring new decoders, so the list starts over when the build fingerprint changes. @Singleton class RejectedVideoFormatStore @Inject constructor(@ApplicationContext context: Context) { private val preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackInterruptionRecovery.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackInterruptionRecovery.kt index 2ae1e196..dbb1592c 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackInterruptionRecovery.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackInterruptionRecovery.kt @@ -10,9 +10,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -// Restarts an item whose playback failed for a reason worth retrying: a fresh SABR session at the current position, -// or a new prepare for other streams. It owns the recovery window of the current item and publishes, through -// PlaybackRecoveryStatus, whether it is still trying, so the player UI never has to guess from the exception. internal class PlaybackInterruptionRecovery( private val player: Player, private val coordinator: SabrPlaybackSeekCoordinator, diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt index 48fcd67c..830c90d6 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryCause.kt @@ -11,26 +11,18 @@ import java.net.SocketException import java.net.UnknownHostException import javax.net.ssl.SSLException -// What a playback failure asks for. Every recovery layer reads this instead of matching exceptions itself. internal enum class PlaybackRecoveryCause { - // No route or a broken connection: retry once a network is there. Network, - // The server answered with a temporary error or is still busy: retry the same request later. Server, - // The server lost, refused or stalled the session: a fresh session at the same position plays on. Session, - // The server switched quality or needs a lower one: the media item has to be rebuilt. Rebuild, - // Nothing to retry; the user sees why. Fatal, } -// The deepest failure that says something wins: Media3 and the window coordinator wrap the real cause in a plain -// IOException, which alone does not mean the network failed. @OptIn(UnstableApi::class) internal fun Throwable.playbackRecoveryCause(): PlaybackRecoveryCause { var transport = false @@ -70,12 +62,10 @@ private fun SabrPlaybackRecoveryException.recoveryCause(): PlaybackRecoveryCause private fun CodedFailure.recoveryCause(): PlaybackRecoveryCause = when { failureCode in SESSION_FAILURE_CODES -> PlaybackRecoveryCause.Session - // The server named what is gone, such as a deleted video: a fresh session would only find the same. failureCode != null && statusCode in GONE_STATUS_CODES -> PlaybackRecoveryCause.Fatal else -> statusCode?.recoveryCause() ?: PlaybackRecoveryCause.Fatal } -// A missing subtitle or thumbnail is not a lost session: only SABR media requests say anything about the session. @OptIn(UnstableApi::class) private fun HttpDataSource.InvalidResponseCodeException.recoveryCause(): PlaybackRecoveryCause = responseCode.recoveryCause(sabrMedia = dataSpec.uri.pathSegments.isSabrPlaybackPayloadPath()) diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryPolicy.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryPolicy.kt index 2a95f650..87333b47 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryPolicy.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryPolicy.kt @@ -1,7 +1,5 @@ package dev.typetype.android.services -// How long playback keeps trying on its own before the user sees an error, and how far apart the attempts are. -// Matches the TV client: two minutes, starting at one second and doubling up to eight. internal class PlaybackRecoveryPolicy( val windowMs: Long = 120_000L, private val baseDelayMs: Long = 1_000L, diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryStatus.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryStatus.kt index 11993842..6bfd3a4d 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryStatus.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryStatus.kt @@ -14,8 +14,6 @@ sealed interface PlaybackRecoveryState { data class GaveUp(val mediaId: String) : PlaybackRecoveryState } -// What the playback service is doing about a failed item. The player shows buffering while the service retries, -// and the error only once it gave up or the failure cannot be retried, instead of guessing from the exception. @Singleton class PlaybackRecoveryStatus @Inject constructor() { private val mutableState = MutableStateFlow(PlaybackRecoveryState.Idle) diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryTracker.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryTracker.kt index fdc8a5d1..a2c38fe0 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryTracker.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackRecoveryTracker.kt @@ -10,9 +10,6 @@ internal sealed interface PlaybackRecoveryDecision { data object GiveUp : PlaybackRecoveryDecision } -// Decides, for the current media item, whether a failed playback is restarted and when. The recovery window opens -// at the first failure and only closes after a stretch of clean playback, so a stream that keeps failing between -// short successes still ends on an error instead of retrying forever. internal class PlaybackRecoveryTracker( private val policy: PlaybackRecoveryPolicy = PlaybackRecoveryPolicy(), private val clock: () -> Long, @@ -41,7 +38,6 @@ internal class PlaybackRecoveryTracker( return PlaybackRecoveryDecision.RetryAfter(policy.delayMs(attempt)) } - // A new route ends the wait at once; the attempt counts toward the same window. fun networkReturned(): Boolean { if (!waitingForNetwork) return false waitingForNetwork = false diff --git a/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt b/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt index 07d1751c..0208af9f 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt @@ -124,8 +124,6 @@ internal class SabrPlaybackServiceBridge( interruption.failed(error) } - // The server switched or lowered the video quality: the media item is rebuilt around the new session or itag. - // Returns false when that is not possible, so the failure goes through the ordinary retries instead. private fun rebuild(error: PlaybackException): Boolean { val state = player.currentMediaItem?.sabrPlaybackSeekState() ?: return false val replacement = error.sabrSessionReplacementFailure() diff --git a/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt b/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt index a01a9382..4852abf9 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt @@ -39,8 +39,6 @@ internal class SabrPlaybackWindowLoader( private val scope = CoroutineScope(SupervisorJob() + context) private var currentWindow = initialWindow - // The service matches recovery requests against the media item, which keeps the session it was built with. A - // stuck session replaced in place only moves the transport binding, so requests name the original session. private val sourceSessionId = transportState.currentBinding().sessionId override fun load( diff --git a/core/playback/src/main/java/dev/typetype/android/services/SabrWindowRecovery.kt b/core/playback/src/main/java/dev/typetype/android/services/SabrWindowRecovery.kt index c36829c3..6c297394 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/SabrWindowRecovery.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/SabrWindowRecovery.kt @@ -7,9 +7,6 @@ import dev.typetype.android.domain.stream.SabrPlaybackSession import dev.typetype.android.domain.stream.SabrPlaybackTarget import kotlinx.coroutines.delay -// Keeps asking for the next window until the recovery window runs out, inside the media source: the player goes on -// with its buffer and media item, and nothing is shown unless the buffer runs dry. A lost or stalled session is -// replaced by a fresh one at the player position; a quality change still needs the service to rebuild the item. internal class SabrWindowRecovery( private val repository: SabrPlaybackRepository, private val target: SabrPlaybackTarget, diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 5bdcf428..8d829811 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -7,6 +7,5 @@ android { } dependencies { - // Shared drawables tint with Material theme attributes such as ?attr/colorOnSurface. api(libs.material) } diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 4463e2a5..fda11861 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -65,6 +65,16 @@ Short title Title copied Rewind + +%1$d s + −%1$d s + + Forward %1$d second + Forward %1$d seconds + + + Back %1$d second + Back %1$d seconds + Previous video Play / pause Play @@ -1251,11 +1261,33 @@ Open your push distributor and complete its pending action Registration failed %1$d of %2$d devices registered - Install a UnifiedPush distributor such as ntfy to receive notifications. + Install a UnifiedPush distributor to get alerts as soon as a video is out. We recommend Sunup. + Get Sunup Choose distributor Couldn\'t update push notifications The app also checks for new videos every 15 minutes - Push alerts are sent only for channels where you turned on the bell. + Channels + Choose which channels alert you about new videos + + %1$d of %2$d channel sends alerts + %1$d of %2$d channels send alerts + + Turn all on + Turn all off + + %1$d new video + %1$d new videos + + + +%1$d more video + +%1$d more videos + + Updating %1$d / %2$d + Some channels could not be updated + Your channels could not be loaded + Subscribe to channels to get new video alerts + Notifications are turned off for TypeType + Allow Turn on new video notifications Turn off new video notifications Disable battery optimization for reliable notification delivery. diff --git a/core/resources/src/main/res/xml/network_security_config.xml b/core/resources/src/main/res/xml/network_security_config.xml index 9b45b1be..3aad9801 100644 --- a/core/resources/src/main/res/xml/network_security_config.xml +++ b/core/resources/src/main/res/xml/network_security_config.xml @@ -1,5 +1,4 @@ - = _state.asStateFlow() - // Opening a video the player already holds, such as one restored paused at launch, must still start it. private val _playRequests = MutableSharedFlow(extraBufferCapacity = 1) val playRequests: SharedFlow = _playRequests diff --git a/deploy/fdroid-beta/sync-github-beta-release.sh b/deploy/fdroid-beta/sync-github-beta-release.sh index 637e9215..135a2edd 100644 --- a/deploy/fdroid-beta/sync-github-beta-release.sh +++ b/deploy/fdroid-beta/sync-github-beta-release.sh @@ -11,33 +11,40 @@ image=typetype-fdroidserver:beta-patched exec 8>"$state_dir/sync.lock" flock 8 -release_json="$( - curl -fsSL --retry 5 \ - "https://api.github.com/repos/${github_repo}/releases?per_page=100" | - jq -c '[.[] | select(.draft == false and .prerelease == true)] | first // empty' -)" - -if [ -z "$release_json" ]; then - echo "No published TypeType Android prerelease was found." >&2 - exit 1 -fi +for attempt in $(seq 1 30); do + release_json="$( + curl -fsSL --retry 5 \ + "https://api.github.com/repos/${github_repo}/releases?per_page=100" | + jq -c '[.[] | select(.draft == false and .prerelease == true)] | first // empty' + )" + + if [ -z "$release_json" ]; then + echo "No published TypeType Android prerelease was found." >&2 + exit 1 + fi -release_id="$(jq -er '.id' <<<"$release_json")" -release_tag="$(jq -er '.tag_name' <<<"$release_json")" -release_notes="$(jq -er '.body // ""' <<<"$release_json")" + release_id="$(jq -er '.id' <<<"$release_json")" + release_tag="$(jq -er '.tag_name' <<<"$release_json")" + release_notes="$(jq -er '.body // ""' <<<"$release_json")" -if [ -f "${state_dir}/published-beta-release-id" ] && [ "$(<"${state_dir}/published-beta-release-id")" = "$release_id" ]; then - exit 0 -fi + if [ -f "${state_dir}/published-beta-release-id" ] && [ "$(<"${state_dir}/published-beta-release-id")" = "$release_id" ]; then + exit 0 + fi -mapfile -t apk_assets < <( - jq -er '.assets[] | select(.name | endswith(".apk")) | "\(.id)\t\(.name)"' <<<"$release_json" -) + mapfile -t apk_assets < <( + jq -r '.assets[] | select(.state == "uploaded" and (.name | endswith(".apk"))) | "\(.id)\t\(.name)"' <<<"$release_json" + ) + checksum_count="$(jq '[.assets[] | select(.state == "uploaded" and (.name | endswith(".apk.sha256")))] | length' <<<"$release_json")" -if [ "${#apk_assets[@]}" -ne 1 ]; then - echo "Expected exactly one APK asset in prerelease ${release_tag}." >&2 - exit 1 -fi + if [ "${#apk_assets[@]}" -eq 1 ] && [ "$checksum_count" -eq 1 ]; then + break + fi + if [ "$attempt" -eq 30 ]; then + echo "Expected exactly one uploaded APK and checksum in prerelease ${release_tag}." >&2 + exit 1 + fi + sleep 30 +done IFS=$'\t' read -r apk_id apk_name <<<"${apk_assets[0]}" checksum_name="${apk_name}.sha256" diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/PlayRequestEffect.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/PlayRequestEffect.kt index 676c2ce6..a4236a5d 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/PlayRequestEffect.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/PlayRequestEffect.kt @@ -6,7 +6,6 @@ import androidx.media3.common.Player import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter -// A request to open the video the player already holds starts it, even if it was restored or left paused. @Composable internal fun PlayRequestEffect(player: Player?, videoUrl: String, playRequests: Flow) { LaunchedEffect(player, videoUrl, playRequests) { diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt index fb2dc143..a5176c43 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt @@ -49,8 +49,6 @@ internal fun codecFailure(errorCode: Int, messages: List): CodecFailure PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, -> CodecFailure.Lasting - // Init also fails when the output surface died with its screen, often with no message saying so. That - // says nothing about the codec, so the rejection ends when the app restarts. PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> if (mentionsSurface) CodecFailure.None else CodecFailure.ThisSession else -> CodecFailure.None diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index 9d5f99fc..8aba2884 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -187,7 +187,6 @@ private fun BottomBar( onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { - // I SPEND 2 HOURS FIGURING OUT HOW TO ALIGN THIS FREAKING SEEKBAR WITH THE BUTTON FULLSCREEN IM SUCH A FAILURE Row( modifier = modifier .height( diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt index b675c9db..6ea73bd2 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt @@ -1,14 +1,8 @@ package dev.typetype.android.feature.player.components -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -19,45 +13,15 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp -import dev.typetype.android.feature.player.state.GestureSide import dev.typetype.android.feature.player.state.PlayerGestureState -import kotlin.math.abs -import kotlin.math.sin private const val SEEK_HINT_VISIBLE_MS = 650 -@Composable -internal fun SeekHintOverlay(state: PlayerGestureState) { - val side = state.seekHintSide.value - val pulse = state.seekHintPulse.longValue - val animation = remember { Animatable(0f) } - LaunchedEffect(side, pulse) { - if (side != null) { - animation.snapTo(0f) - animation.animateTo( - targetValue = 1f, - animationSpec = tween(SEEK_HINT_VISIBLE_MS, easing = LinearEasing), - ) - state.seekHintSide.value = null - } - } - if (side != null) { - SeekTapWave( - side = side, - progress = animation.value, - seconds = state.seekHintSeconds.floatValue.toInt(), - ) - } -} - @Composable internal fun PlaybackHintOverlay(state: PlayerGestureState) { val playing = state.playbackHintPlaying.value @@ -86,50 +50,6 @@ internal fun PlaybackHintOverlay(state: PlayerGestureState) { } } -@Composable -private fun SeekTapWave( - side: GestureSide, - progress: Float, - seconds: Int, -) { - Box(modifier = Modifier.fillMaxSize()) { - Canvas( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.48f) - .align(if (side == GestureSide.Left) Alignment.CenterStart else Alignment.CenterEnd), - ) { - val originX = if (side == GestureSide.Left) size.width * 0.7f else size.width * 0.3f - val origin = Offset(originX, size.height / 2f) - repeat(3) { index -> - val phase = (progress - index * 0.18f).coerceIn(0f, 1f) - if (phase <= 0f || phase >= 1f) return@repeat - val radius = size.minDimension * (0.08f + phase * 0.5f) - drawArc( - color = Color.White.copy(alpha = (1f - phase) * 0.9f), - startAngle = if (side == GestureSide.Left) 125f else -55f, - sweepAngle = 110f, - useCenter = false, - topLeft = Offset(origin.x - radius, origin.y - radius), - size = androidx.compose.ui.geometry.Size(radius * 2f, radius * 2f), - style = androidx.compose.ui.graphics.drawscope.Stroke( - width = 3.dp.toPx(), - cap = StrokeCap.Round, - ), - ) - } - } - Text( - text = if (side == GestureSide.Left) "-${seconds}s" else "+${seconds}s", - modifier = Modifier - .align(if (side == GestureSide.Left) Alignment.CenterStart else Alignment.CenterEnd) - .padding(horizontal = 56.dp), - color = Color.White, - style = MaterialTheme.typography.headlineSmall, - ) - } -} - @Composable internal fun SpeedBoostBadge(visible: Boolean, factor: Float) { if (!visible) return diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerPlaybackStatus.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerPlaybackStatus.kt index 7d317bfd..4e6d00b2 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerPlaybackStatus.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/components/PlayerPlaybackStatus.kt @@ -31,13 +31,10 @@ data class PlayerPlaybackStatus( get() = error == null && !isRecovering } -// What the playback service reports about a failed item; the app root provides the service's status. val LocalPlaybackRecovery = staticCompositionLocalOf> { NO_RECOVERY } private val NO_RECOVERY: StateFlow = MutableStateFlow(PlaybackRecoveryState.Idle) -// While the service retries a failed item the player shows buffering; its error appears only once the service -// gave up or the failure cannot be retried. @Composable fun rememberPlayerPlaybackStatus(player: Player): PlayerPlaybackStatus { var playbackState by remember(player) { mutableIntStateOf(player.playbackState) } diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/components/SeekHintOverlay.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/components/SeekHintOverlay.kt new file mode 100644 index 00000000..78dc4a5b --- /dev/null +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/components/SeekHintOverlay.kt @@ -0,0 +1,168 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.feature.player.state.GestureSide +import dev.typetype.android.feature.player.state.PlayerGestureState +import dev.typetype.android.R +import kotlinx.coroutines.delay +import kotlin.math.abs + +private const val SEEK_HINT_HOLD_MS = 700L +private const val CHEVRON_CYCLE_MS = 720 +private const val CHEVRON_COUNT = 3 +private val PanelColor = Color.Black.copy(alpha = 0.34f) + +@Composable +internal fun SeekHintOverlay(state: PlayerGestureState) { + val visible = state.seekHintVisible.value + val pulse = state.seekHintPulse.longValue + LaunchedEffect(pulse) { + if (state.seekHintVisible.value) { + delay(SEEK_HINT_HOLD_MS) + state.hideSeekHint() + } + } + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(durationMillis = 90)), + exit = fadeOut(tween(durationMillis = 220)), + modifier = Modifier.fillMaxSize(), + ) { + val side = state.seekHintSide.value + Box(modifier = Modifier.fillMaxSize()) { + SeekSidePanel( + side = side, + seconds = state.seekHintSeconds.intValue, + pulse = pulse, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.4f) + .align(if (side == GestureSide.Left) Alignment.CenterStart else Alignment.CenterEnd), + ) + } + } +} + +@Composable +private fun SeekSidePanel(side: GestureSide, seconds: Int, pulse: Long, modifier: Modifier) { + val bump = remember { Animatable(1f) } + LaunchedEffect(pulse) { + bump.snapTo(1.14f) + bump.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMediumLow)) + } + val forward = side == GestureSide.Right + val label = stringResource( + if (forward) R.string.player_seek_forward_hint else R.string.player_seek_rewind_hint, + seconds, + ) + val announcement = pluralStringResource( + if (forward) R.plurals.player_seek_forward_announcement else R.plurals.player_seek_rewind_announcement, + seconds, + seconds, + ) + Box( + modifier = modifier + .clipToBounds() + .drawBehind { + val radius = size.height * 0.9f + val centerX = if (forward) radius else size.width - radius + drawCircle(PanelColor, radius = radius, center = Offset(centerX, size.height / 2f)) + }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SeekChevrons(forward = forward) + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .graphicsLayer { + scaleX = bump.value + scaleY = bump.value + } + .semantics { + liveRegion = LiveRegionMode.Polite + contentDescription = announcement + }, + ) + } + } +} + +@Composable +private fun SeekChevrons(forward: Boolean) { + val transition = rememberInfiniteTransition(label = "seekChevrons") + val cycle: State = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(CHEVRON_CYCLE_MS, easing = LinearEasing), RepeatMode.Restart), + label = "seekChevronCycle", + ) + Canvas( + modifier = Modifier + .size(width = 54.dp, height = 18.dp) + .graphicsLayer { scaleX = if (forward) 1f else -1f }, + ) { + val step = size.width / CHEVRON_COUNT + val height = size.height + val width = height * 0.8f + repeat(CHEVRON_COUNT) { index -> + val phase = ((cycle.value - index / CHEVRON_COUNT.toFloat()) % 1f + 1f) % 1f + val glow = 1f - abs(phase * 2f - 1f) + val left = index * step + (step - width) / 2f + val path = Path().apply { + moveTo(left, 0f) + lineTo(left + width, height / 2f) + lineTo(left, height) + close() + } + drawPath(path, Color.White.copy(alpha = 0.25f + 0.75f * glow)) + } + } +} diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/components/ShortsVideoSurface.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/components/ShortsVideoSurface.kt index 5fcf843d..2f394ec5 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/components/ShortsVideoSurface.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/components/ShortsVideoSurface.kt @@ -10,8 +10,6 @@ import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW import dev.typetype.android.feature.player.state.ResizeMode -// Shorts only need the picture: their controls and subtitles are drawn in Compose. A Compose surface avoids -// building a full PlayerView, about 9 ms of every page change on a mid-range phone. @OptIn(UnstableApi::class) @Composable internal fun ShortsVideoSurface(player: Player, resizeMode: ResizeMode, modifier: Modifier = Modifier) { diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt index a340b329..3c51e3ae 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -19,8 +20,9 @@ fun ResizeMode.next(): ResizeMode = when (this) { @Stable class PlayerGestureState { - val seekHintSide: MutableState = mutableStateOf(null) - val seekHintSeconds = mutableFloatStateOf(0f) + val seekHintSide: MutableState = mutableStateOf(GestureSide.Right) + val seekHintVisible: MutableState = mutableStateOf(false) + val seekHintSeconds = mutableIntStateOf(0) val seekHintPulse: MutableLongState = mutableLongStateOf(0L) val playbackHintPlaying: MutableState = mutableStateOf(null) val playbackHintPulse: MutableLongState = mutableLongStateOf(0L) @@ -37,11 +39,17 @@ class PlayerGestureState { val longPressBoostActive: MutableState = mutableStateOf(false) fun showSeekHint(side: GestureSide, seconds: Int) { + val continuing = seekHintVisible.value && seekHintSide.value == side + seekHintSeconds.intValue = if (continuing) seekHintSeconds.intValue + seconds else seconds seekHintSide.value = side - seekHintSeconds.floatValue = seconds.toFloat() + seekHintVisible.value = true seekHintPulse.longValue += 1L } + fun hideSeekHint() { + seekHintVisible.value = false + } + fun showPlaybackHint(isPlaying: Boolean) { playbackHintPlaying.value = isPlaying playbackHintPulse.longValue += 1L diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsScreen.kt new file mode 100644 index 00000000..864741ca --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsScreen.kt @@ -0,0 +1,231 @@ +package dev.typetype.android.feature.settings.notifications + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.ChannelAvatar +import dev.typetype.android.core.ui.components.TypeTypeSwitch +import dev.typetype.android.feature.settings.SettingsDetailTopBar + +@Composable +fun ChannelNotificationsRoute( + onNavigateBack: () -> Unit, + viewModel: ChannelNotificationsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + ChannelNotificationsScreen(state = state, onNavigateBack = onNavigateBack, onAction = viewModel::onAction) +} + +@Composable +internal fun ChannelNotificationsScreen( + state: ChannelNotificationsState, + onNavigateBack: () -> Unit, + onAction: (ChannelNotificationsAction) -> Unit, +) { + val context = LocalContext.current + var allowed by remember { mutableStateOf(context.notificationsAllowed()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { allowed = context.notificationsAllowed() } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + allowed = context.notificationsAllowed() + } + val dispatch: (ChannelNotificationsAction) -> Unit = { action -> + if (!allowed && action.turnsOn(state) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + onAction(action) + } + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + SettingsDetailTopBar( + title = stringResource(R.string.push_channels_title), + onNavigateBack = onNavigateBack, + ) + when { + state.isLoading && state.channels.isEmpty() -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + state.loadFailed && state.channels.isEmpty() -> LoadFailed(onRetry = { onAction(ChannelNotificationsAction.Retry) }) + else -> ChannelList( + state = state, + allowed = allowed, + onAllow = context::openNotificationSettings, + onAction = dispatch, + ) + } + } + } +} + +@Composable +private fun ChannelList( + state: ChannelNotificationsState, + allowed: Boolean, + onAllow: () -> Unit, + onAction: (ChannelNotificationsAction) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item(key = "summary") { + Column(modifier = Modifier.padding(20.dp, 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = pluralStringResource( + R.plurals.push_channels_summary, + state.channels.size, + state.enabledCount, + state.channels.size, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { onAction(ChannelNotificationsAction.SetAll(true)) }, + enabled = !state.bulkInFlight && state.enabledCount < state.channels.size, + ) { Text(stringResource(R.string.push_channels_enable_all)) } + OutlinedButton( + onClick = { onAction(ChannelNotificationsAction.SetAll(false)) }, + enabled = !state.bulkInFlight && state.enabledCount > 0, + ) { Text(stringResource(R.string.push_channels_disable_all)) } + } + if (state.bulkInFlight) { + LinearProgressIndicator( + progress = { state.bulkDone / state.bulkTotal.coerceAtLeast(1).toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(R.string.push_channels_progress, state.bulkDone, state.bulkTotal), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (state.showError) { + Text( + text = stringResource(R.string.push_channels_update_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (!allowed && state.enabledCount > 0) NotificationsBlocked(onAllow = onAllow) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + if (state.channels.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.push_channels_empty), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(20.dp, 16.dp), + ) + } + } + items(state.channels, key = { it.channelUrl }) { channel -> + ChannelBellRow( + channel = channel, + enabled = !state.bulkInFlight && !channel.pending, + onToggle = { onAction(ChannelNotificationsAction.Toggle(channel.channelUrl)) }, + ) + } + } +} + +@Composable +private fun ChannelBellRow(channel: ChannelBell, enabled: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = channel.enabled, enabled = enabled, role = Role.Switch, onValueChange = { onToggle() }) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ChannelAvatar(avatarUrl = channel.avatarUrl, name = channel.name, size = 40.dp) + Text( + text = channel.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + TypeTypeSwitch(checked = channel.enabled, onCheckedChange = null) + } +} + +@Composable +private fun NotificationsBlocked(onAllow: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.push_notifications_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onAllow) { Text(stringResource(R.string.push_notifications_allow)) } + } +} + +@Composable +private fun LoadFailed(onRetry: () -> Unit) { + Column(modifier = Modifier.padding(20.dp, 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.push_channels_load_failed), style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) } + } +} + +private fun ChannelNotificationsAction.turnsOn(state: ChannelNotificationsState): Boolean = when (this) { + is ChannelNotificationsAction.SetAll -> enabled + is ChannelNotificationsAction.Toggle -> state.channels.firstOrNull { it.channelUrl == channelUrl }?.enabled == false + ChannelNotificationsAction.Retry -> false +} + +private fun Context.notificationsAllowed(): Boolean = NotificationManagerCompat.from(this).areNotificationsEnabled() + +private fun Context.openNotificationSettings() { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)) + } + startActivity(intent) +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt new file mode 100644 index 00000000..ab149605 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/ChannelNotificationsViewModel.kt @@ -0,0 +1,163 @@ +package dev.typetype.android.feature.settings.notifications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.core.error.suspendRunCatching +import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.domain.push.matchesChannel +import dev.typetype.android.domain.subscriptions.SubscriptionsRepository +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +data class ChannelBell( + val channelUrl: String, + val name: String, + val avatarUrl: String, + val enabled: Boolean, + val pending: Boolean = false, +) + +data class ChannelNotificationsState( + val isLoading: Boolean = true, + val loadFailed: Boolean = false, + val channels: List = emptyList(), + val bulkInFlight: Boolean = false, + val bulkDone: Int = 0, + val bulkTotal: Int = 0, + val showError: Boolean = false, +) { + val enabledCount: Int get() = channels.count { it.enabled } +} + +sealed interface ChannelNotificationsAction { + data object Retry : ChannelNotificationsAction + + data class Toggle(val channelUrl: String) : ChannelNotificationsAction + + data class SetAll(val enabled: Boolean) : ChannelNotificationsAction +} + +@HiltViewModel +class ChannelNotificationsViewModel @Inject constructor( + private val subscriptions: SubscriptionsRepository, + private val pushRepository: PushRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ChannelNotificationsState()) + val state = _state.asStateFlow() + + init { + load() + } + + fun onAction(action: ChannelNotificationsAction) { + when (action) { + ChannelNotificationsAction.Retry -> load() + is ChannelNotificationsAction.Toggle -> toggle(action.channelUrl) + is ChannelNotificationsAction.SetAll -> setAll(action.enabled) + } + } + + private fun load() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, loadFailed = false, showError = false) } + val channels = suspendRunCatching { + val subscribed = subscriptions.listSubscriptions().getOrThrow() + val bells = pushRepository.channelPreferences().getOrThrow().filter { it.enabled } + subscribed + .sortedBy { it.name.lowercase() } + .map { channel -> + ChannelBell( + channelUrl = channel.channelUrl, + name = channel.name, + avatarUrl = channel.avatarUrl, + enabled = bells.any { it.matchesChannel(channel.channelUrl) }, + ) + } + } + _state.update { + it.copy( + isLoading = false, + loadFailed = channels.isFailure, + channels = channels.getOrDefault(it.channels), + ) + } + } + } + + private fun toggle(channelUrl: String) { + val current = _state.value + val channel = current.channels.firstOrNull { it.channelUrl == channelUrl } ?: return + if (channel.pending || current.bulkInFlight) return + viewModelScope.launch { + val saved = save(channel, !channel.enabled) + if (!saved) _state.update { it.copy(showError = true) } + } + } + + private fun setAll(enabled: Boolean) { + val current = _state.value + if (current.bulkInFlight) return + val targets = current.channels.filter { it.enabled != enabled && !it.pending } + if (targets.isEmpty()) return + viewModelScope.launch { + _state.update { it.copy(bulkInFlight = true, bulkDone = 0, bulkTotal = targets.size, showError = false) } + val gate = Semaphore(BULK_PARALLELISM) + val results = coroutineScope { + targets.map { channel -> + async { + gate.withPermit { save(channel, enabled) } + .also { _state.update { it.copy(bulkDone = it.bulkDone + 1) } } + } + }.awaitAll() + } + _state.update { it.copy(bulkInFlight = false, showError = results.any { saved -> !saved }) } + } + } + + private suspend fun save(channel: ChannelBell, enabled: Boolean): Boolean { + updateChannel(channel.channelUrl) { it.copy(enabled = enabled, pending = true) } + val saved = sendWithRetry(channel.channelUrl, enabled) + updateChannel(channel.channelUrl) { + it.copy(enabled = if (saved) enabled else !enabled, pending = false) + } + return saved + } + + private suspend fun sendWithRetry(channelUrl: String, enabled: Boolean): Boolean { + repeat(MAX_ATTEMPTS) { + val result = suspendRunCatching { + pushRepository.setChannelPreference(channelUrl, enabled).getOrThrow() + } + val failure = result.exceptionOrNull() ?: return true + if ((failure as? CodedFailure)?.statusCode != HTTP_TOO_MANY_REQUESTS) return false + delay(RATE_LIMIT_PAUSE_MS) + } + return false + } + + private fun updateChannel(channelUrl: String, transform: (ChannelBell) -> ChannelBell) { + _state.update { state -> + state.copy( + channels = state.channels.map { if (it.channelUrl == channelUrl) transform(it) else it }, + ) + } + } + + private companion object { + const val BULK_PARALLELISM = 4 + const val MAX_ATTEMPTS = 5 + const val HTTP_TOO_MANY_REQUESTS = 429 + const val RATE_LIMIT_PAUSE_MS = 20_000L + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index bca5c563..9a0eb33a 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -26,12 +26,18 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R @@ -43,12 +49,14 @@ import org.unifiedpush.android.connector.UnifiedPush @Composable fun PushNotificationsRoute( onNavigateBack: () -> Unit, + onOpenChannels: () -> Unit, viewModel: PushNotificationsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() PushNotificationsScreen( state = state, onNavigateBack = onNavigateBack, + onOpenChannels = onOpenChannels, onAction = viewModel::onAction, ) } @@ -57,6 +65,7 @@ fun PushNotificationsRoute( internal fun PushNotificationsScreen( state: PushSettingsState, onNavigateBack: () -> Unit, + onOpenChannels: () -> Unit, onAction: (PushSettingsAction) -> Unit, ) { val context = LocalContext.current @@ -87,9 +96,14 @@ internal fun PushNotificationsScreen( } }, ) + ChannelsRow(onClick = onOpenChannels) StatusRow(label = stringResource(statusLabel(state.status))) - if (state.status == PushRegistrationStatus.MissingDistributor) { - DistributorRow() + var hasDistributor by remember { mutableStateOf(UnifiedPush.getDistributors(context).isNotEmpty()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + hasDistributor = UnifiedPush.getDistributors(context).isNotEmpty() + } + if (state.status == PushRegistrationStatus.MissingDistributor || !hasDistributor) { + DistributorRow(hasDistributor = hasDistributor) } Text( text = stringResource(R.string.push_local_fallback), @@ -97,12 +111,6 @@ internal fun PushNotificationsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(20.dp, 4.dp), ) - Text( - text = stringResource(R.string.push_channel_bell_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(20.dp, 4.dp), - ) if (state.capabilityEnabled && state.deviceCount > 0) { StatusRow( label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices), @@ -155,6 +163,27 @@ private fun PushToggleRow( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } +@Composable +private fun ChannelsRow(onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(20.dp, 12.dp), + ) { + Text( + text = stringResource(R.string.push_channels_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.push_channels_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + @Composable private fun StatusRow(label: String) { Text( @@ -167,22 +196,28 @@ private fun StatusRow(label: String) { } @Composable -private fun DistributorRow() { +private fun DistributorRow(hasDistributor: Boolean) { val context = LocalContext.current Text( text = stringResource(R.string.push_install_hint), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(20.dp, 12.dp), ) - TextButton( - onClick = { - (context as? Activity)?.let { activity -> - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { } + Row(modifier = Modifier.padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, SUNUP_URL.toUri())) }) { + Text(stringResource(R.string.push_get_sunup)) + } + if (hasDistributor) { + TextButton( + onClick = { + (context as? Activity)?.let { activity -> + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { } + } + }, + ) { + Text(stringResource(R.string.push_choose_distributor)) } - }, - modifier = Modifier.padding(horizontal = 20.dp), - ) { - Text(stringResource(R.string.push_choose_distributor)) + } } HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -211,6 +246,8 @@ private fun BatteryHintRow() { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } +private const val SUNUP_URL = "https://unifiedpush.org/users/distributors/sunup/" + private fun PushRegistrationStatus.isRegistered(): Boolean = this == PushRegistrationStatus.Registered || this == PushRegistrationStatus.Registering diff --git a/feature/setup/src/main/java/dev/typetype/android/feature/setup/addserver/LocalNetworkPermissionPolicy.kt b/feature/setup/src/main/java/dev/typetype/android/feature/setup/addserver/LocalNetworkPermissionPolicy.kt index f06b3857..40db170d 100644 --- a/feature/setup/src/main/java/dev/typetype/android/feature/setup/addserver/LocalNetworkPermissionPolicy.kt +++ b/feature/setup/src/main/java/dev/typetype/android/feature/setup/addserver/LocalNetworkPermissionPolicy.kt @@ -3,8 +3,6 @@ package dev.typetype.android.feature.setup.addserver import android.Manifest import android.annotation.SuppressLint -// The permission name is a string inlined at compile time. Below API 37 it is never requested, and checking it -// only reports "denied", which localNetworkPermissionRequired ignores there. @SuppressLint("InlinedApi") internal const val LOCAL_NETWORK_PERMISSION = Manifest.permission.ACCESS_LOCAL_NETWORK diff --git a/feature/shorts/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt b/feature/shorts/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt index 76625177..9429dd41 100644 --- a/feature/shorts/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt +++ b/feature/shorts/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt @@ -269,7 +269,6 @@ class ShortsScreenTest { statsForVideo = { ShortsVideoStats(viewCount = 1_500, likeCount = 42) }, ) - // The counts follow the device locale, so the expected labels use the same formatter. composeRule.onNodeWithContentDescription("${formatShortsCount(1_500L)} views").assertIsDisplayed() composeRule.onNodeWithContentDescription("${formatShortsCount(42L)} likes").assertIsDisplayed() } diff --git a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt index 7de85e64..6b8de1b6 100644 --- a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt +++ b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt @@ -44,7 +44,6 @@ internal fun shortsOverlayAlpha(pageOffset: Float): Float = internal fun shortsOverlayTranslationY(pageOffset: Float): Float = pageOffset.coerceIn(0f, 1f) * 24f -// overlayMotion animates the overlay only; the page itself takes no outside modifier. @SuppressLint("ModifierParameter") @Composable internal fun ShortPage( diff --git a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsPrefetchPolicy.kt b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsPrefetchPolicy.kt index fcd03425..fd40b3ea 100644 --- a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsPrefetchPolicy.kt +++ b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsPrefetchPolicy.kt @@ -15,7 +15,6 @@ internal fun Context.allowsShortsPlaybackPrefetch(): Boolean { ) } -// The status constant is inlined; sdkInt keeps it from being read below API 24. @SuppressLint("InlinedApi") internal fun allowsShortsPlaybackPrefetch( sdkInt: Int, diff --git a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt index 6be41388..37b66529 100644 --- a/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt +++ b/feature/shorts/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt @@ -50,8 +50,6 @@ fun ShortsRoute( val currentMediaId = rememberCurrentMediaId(mediaController) val playbackStatus = mediaController?.let { rememberPlayerPlaybackStatus(it) } val menuScope = rememberVideoMenuScope(onOpenChannel) - // Player state changes several times while a Short opens. A fresh copy on each of them would make every - // composed page recompose in the middle of the swipe. val visibleState = remember(state, menuScope) { state.copy(videos = state.videos.filterNot(menuScope::isHidden)) } diff --git a/gradle.properties b/gradle.properties index 49c1e7b9..508aa870 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,19 +1,5 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -Djava.awt.headless=true -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -# Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -# Modules compile in parallel in the Kotlin daemon; in-process compilation serialized them inside Gradle. kotlin.daemon.jvmargs=-Xmx3g android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt b/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt index f2862f66..26f6f7d6 100644 --- a/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt +++ b/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt @@ -289,7 +289,7 @@ internal fun playbackWindowRefreshThresholdUs( ): Long = if (activeLive) { LIVE_WINDOW_REFRESH_THRESHOLD_US } else { - (bufferGoalUs / VOD_REFRESH_WATERMARK_DIVISOR).coerceAtLeast(1L) + maxOf(MIN_VOD_REFRESH_THRESHOLD_US, bufferGoalUs * 2 / 3).coerceIn(1L, bufferGoalUs) } internal fun playbackBufferStartUs( @@ -323,6 +323,6 @@ internal fun retainedPlaybackStartUs(trackStartsUs: List): Long = private const val LOCAL_SEEK_END_MARGIN_US = 250_000L private const val BACK_BUFFER_US = 30_000_000L private const val DEFAULT_VOD_BUFFER_GOAL_US = 10_000_000L -private const val VOD_REFRESH_WATERMARK_DIVISOR = 2L +private const val MIN_VOD_REFRESH_THRESHOLD_US = 5_000_000L private const val LIVE_WINDOW_REFRESH_THRESHOLD_US = 5_000_000L private const val LIVE_WINDOW_POLL_INTERVAL_MS = 250L diff --git a/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt b/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt index 7a422737..fe821167 100644 --- a/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt +++ b/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt @@ -51,8 +51,8 @@ class PlaybackWindowTest { assertFalse( shouldRefreshPlaybackWindow( playbackPositionUs = 10_000_000, - bufferedEndUs = 15_000_000, - availableEndUs = 15_000_000, + bufferedEndUs = 17_000_000, + availableEndUs = 17_000_000, endOfStream = false, bufferGoalUs = 10_000_000, ), @@ -60,7 +60,7 @@ class PlaybackWindowTest { assertFalse( shouldRefreshPlaybackWindow( playbackPositionUs = 10_000_000, - bufferedEndUs = 14_999_999, + bufferedEndUs = 16_000_000, availableEndUs = 20_000_000, endOfStream = false, bufferGoalUs = 10_000_000, @@ -69,8 +69,8 @@ class PlaybackWindowTest { assertTrue( shouldRefreshPlaybackWindow( playbackPositionUs = 10_000_000, - bufferedEndUs = 14_999_999, - availableEndUs = 14_999_999, + bufferedEndUs = 16_000_000, + availableEndUs = 16_000_000, endOfStream = false, bufferGoalUs = 10_000_000, ), @@ -83,8 +83,8 @@ class PlaybackWindowTest { val windowEndUs = 10_000_000L assertFalse(shouldRefreshPlaybackWindow(0L, windowEndUs, windowEndUs, false, 10_000_000L)) - assertFalse(shouldRefreshPlaybackWindow(4_999_999L, windowEndUs, windowEndUs, false, 10_000_000L)) - assertTrue(shouldRefreshPlaybackWindow(5_000_001L, windowEndUs, windowEndUs, false, 10_000_000L)) + assertFalse(shouldRefreshPlaybackWindow(3_333_333L, windowEndUs, windowEndUs, false, 10_000_000L)) + assertTrue(shouldRefreshPlaybackWindow(3_333_335L, windowEndUs, windowEndUs, false, 10_000_000L)) } @Test diff --git a/release-notes/v1.11.0-beta.5.md b/release-notes/v1.11.0-beta.5.md new file mode 100644 index 00000000..64bee58a --- /dev/null +++ b/release-notes/v1.11.0-beta.5.md @@ -0,0 +1,8 @@ +TypeType Android 1.11.0-beta.5 + +- Fix frequent buffering during playback: the next part of the video is now requested as early as in the web player. +- Choose which subscribed channels send new video alerts, or turn them all on or off at once. +- Show the video title and its platform in new video alerts, and group a burst of new videos into a single alert. +- Skip alerts for members-only videos, Shorts, live streams and upcoming premieres. +- Recommend Sunup when no UnifiedPush distributor is installed. +- Replace the double-tap seek wave with a panel that adds up repeated seeks.