Dev: use build product flavors for app store release (#1205)

## Description
Instead of applying a code patch, this PR uses build time [product
flavors](https://developer.android.com/build/build-variants#product-flavors)
to define the existing default and an app store variant. This is more
robust than maintaining a patch file and making sure it is applied
before building.

The app store variant applies the same code changes (removing install
permission, requiring leanback, & setting `UpdateChecker.ACTIVE`), but
via manifest placeholders/merging and `BuildConfig` entries.

There are no user facing changes.

### Related issues
None

### Testing
Built the variants and verified manifest changes via aapt

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-06 15:24:20 -04:00 committed by GitHub
parent 9a3af225a4
commit 31465051ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 91 additions and 165 deletions

View file

@ -54,7 +54,7 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean assembleRelease assembleDebug --no-daemon ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
- name: Verify signatures - name: Verify signatures
run: | run: |
@ -67,7 +67,7 @@ jobs:
find app/build/outputs/apk -name '*.apk' -print0 | find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')"
echo "$line => $short_name" echo "$line => $short_name"
cp "$line" "$short_name" cp "$line" "$short_name"
done done

View file

@ -39,19 +39,6 @@ jobs:
- name: Build app - name: Build app
id: buildapp id: buildapp
run: | run: |
./gradlew clean assembleDebug testDebugUnitTest --no-daemon ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT" echo "apks=$apks" >> "$GITHUB_OUTPUT"
test-patch:
runs-on: ubuntu-latest
needs: pre-commit
steps:
- name: Checkout the code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Test applying patch
run: |
git apply app/src/patches/play_store.patch
git diff

View file

@ -36,22 +36,8 @@ jobs:
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: | run: |
./gradlew clean assembleRelease --no-daemon ./gradlew clean assembleDefaultRelease bundleAppstoreRelease --no-daemon
- name: Build app
id: buildaab
env:
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
run: |
git apply app/src/patches/play_store.patch
./gradlew bundleRelease --no-daemon
aab=$(find app/build/outputs -name '*.aab')
echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Verify signatures - name: Verify signatures
run: | run: |
echo "Verify APK signatures" echo "Verify APK signatures"
@ -62,13 +48,16 @@ jobs:
run: | run: |
find app/build/outputs/apk -name '*.apk' -print0 | find app/build/outputs/apk -name '*.apk' -print0 |
while IFS= read -r -d '' line; do while IFS= read -r -d '' line; do
# Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk
short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')"
echo "$line => $short_name" echo "$line => $short_name"
cp "$line" "$short_name" cp "$line" "$short_name"
done done
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT" echo "apks=$apks" >> "$GITHUB_OUTPUT"
aab=$(find app/build/outputs -name '*.aab')
echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" echo "SHA256 checksums:"

View file

@ -47,6 +47,8 @@ android {
versionCode = gitTags.trim().lines().size versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
} }
signingConfigs { signingConfigs {
@ -108,6 +110,20 @@ android {
} }
} }
} }
flavorDimensions += "version"
productFlavors {
create("default") {
dimension = "version"
isDefault = true
manifestPlaceholders += mapOf("leanback" to false)
buildConfigField("boolean", "UPDATING_ENABLED", "true")
}
create("appstore") {
dimension = "version"
manifestPlaceholders += mapOf("leanback" to true)
buildConfigField("boolean", "UPDATING_ENABLED", "false")
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
</manifest>

View file

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- <service-->
<!-- android:name=".WholphinDreamService"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.service.dreams.DreamService" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </service>-->
</application>
</manifest>

View file

@ -20,7 +20,7 @@
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="${leanback}" />
<uses-feature <uses-feature
android:name="android.hardware.microphone" android:name="android.hardware.microphone"
android:required="false" /> android:required="false" />

View file

@ -65,7 +65,7 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = true const val ACTIVE = BuildConfig.UPDATING_ENABLED
} }
/** /**

View file

@ -54,6 +54,7 @@ import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber import timber.log.Timber
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -75,6 +76,34 @@ class DebugViewModel
display.supportedModes.orEmpty() display.supportedModes.orEmpty()
} }
val av1Included by lazy {
try {
Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer")
true
} catch (_: ClassNotFoundException) {
false
}
}
val ffmpegIncluded by lazy {
try {
Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer")
true
} catch (_: ClassNotFoundException) {
false
}
}
val libMpvLoaded by lazy {
try {
System.loadLibrary("player")
System.loadLibrary("mpv")
true
} catch (_: Exception) {
false
}
}
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let { serverRepository.currentUser.value?.rowId?.let {
@ -218,6 +247,7 @@ fun DebugPage(
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
val buildTime = Date(BuildConfig.BUILD_TIME)
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val installInfo = val installInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@ -239,6 +269,11 @@ fun DebugPage(
"Version Code: ${pkgInfo.versionCodeLong}", "Version Code: ${pkgInfo.versionCodeLong}",
"ClientInfo: ${viewModel.clientInfo}", "ClientInfo: ${viewModel.clientInfo}",
"Build type: ${BuildConfig.BUILD_TYPE}", "Build type: ${BuildConfig.BUILD_TYPE}",
"Build flavor: ${BuildConfig.FLAVOR}",
"Build time: $buildTime",
"FFMPEG included: ${viewModel.ffmpegIncluded}",
"AV1 included: ${viewModel.av1Included}",
"libmpv loaded: ${viewModel.libMpvLoaded}",
"Debug enabled: ${BuildConfig.DEBUG}", "Debug enabled: ${BuildConfig.DEBUG}",
"ABIs: ${Build.SUPPORTED_ABIS.toList()}", "ABIs: ${Build.SUPPORTED_ABIS.toList()}",
) + installInfo ) + installInfo

View file

@ -1,36 +0,0 @@
# This patches Wholphin for releasing on app stores where self updating is not permitted
diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml
index 4479ae86..80596698 100644
--- i/app/src/main/AndroidManifest.xml
+++ w/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
- <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -20,7 +19,7 @@
android:required="false" />
<uses-feature
android:name="android.software.leanback"
- android:required="false" />
+ android:required="true" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index 9e0665dd..bc1e1c16 100644
--- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
+++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
@@ -65,7 +65,7 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
- val ACTIVE = true
+ val ACTIVE = false
}
/**

View file

@ -1,5 +1,7 @@
package com.github.damontecres.wholphin.ui package com.github.damontecres.wholphin.ui
import android.app.Application
import android.content.pm.ActivityInfo
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -14,6 +16,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.requestFocus
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
@ -49,8 +52,11 @@ import org.junit.Assert
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import javax.inject.Inject import javax.inject.Inject
@ -61,7 +67,23 @@ class BasicUiTests {
@get:Rule(order = 0) @get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this) var hiltRule = HiltAndroidRule(this)
// From https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882
@get:Rule(order = 1) @get:Rule(order = 1)
val addActivityToRobolectricRule =
object : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
val appContext: Application = ApplicationProvider.getApplicationContext()
val activityInfo =
ActivityInfo().apply {
name = TestActivity::class.java.name
packageName = appContext.packageName
}
shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo)
}
}
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<TestActivity>() val composeTestRule = createAndroidComposeRule<TestActivity>()
@Inject @Inject