Compare commits

...

13 Commits

Author SHA1 Message Date
Koen f693f1e6b3 Edit deploy-stable.sh 2026-03-12 18:09:21 +00:00
Koen e118bc09b9 Edit deploy-unstable.sh 2026-03-12 18:07:57 +00:00
Kelvin 5ba77b60c8 Merge branch 'marcus/fcast-fix' into 'master'
fix fcast sender sdk

See merge request videostreaming/grayjay!166
2026-03-12 17:42:35 +00:00
Kelvin K 19b63ba372 Remove transient from plugin settings, submods 2026-03-12 12:34:04 -05:00
Marcus Hanestad 5fc39d3bb3 fix fcast sender sdk 2026-03-11 12:51:17 -05:00
Koen 1d046538f8 Merge branch 'marcus/fcast-sdk-0.4.1' into 'master'
upgrade fcast sdk to 0.4.1

See merge request videostreaming/grayjay!165
2026-03-10 13:30:43 +00:00
Marcus Hanestad 9f10b86861 upgrade fcast sdk to 0.4.1 2026-03-10 08:28:52 -05:00
Koen J d1336c711a Changed over deploy location to Cloudflare R2. 2026-03-03 10:37:25 +01:00
Koen 2a2ed08a3c Merge branch 'captcha-improvements' into 'master'
feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent

See merge request videostreaming/grayjay!164
2026-02-27 06:59:36 +00:00
Stefan 8a0e49232e feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent 2026-02-27 06:59:35 +00:00
Koen J a8decdb0d9 Updated FDroid pipeline. 2026-02-19 14:15:05 +01:00
Koen J 2609929780 FDroid automation in pipeline. 2026-02-19 14:06:53 +01:00
Koen J 2bcfbf89d3 Added proper automation for playstore builds. 2026-02-19 11:13:27 +01:00
28 changed files with 601 additions and 143 deletions
+39 -19
View File
@@ -1,37 +1,57 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable
stage: build
script:
- sh deploy-unstable.sh
only:
- tags
except:
- ^(dev)
when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable:
stage: buildAndDeployApkStable
stage: build
script:
- sh deploy-stable.sh
only:
- tags
except:
- branches
when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore:
stage: buildAndDeployPlaystore
stage: deploy
script:
- sh deploy-playstore.sh
- sh build-playstore.sh
- bash tools/venv_playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
only:
- tags
except:
- branches
when: manual
when: on_success
needs:
- buildAndDeployApkStable
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/bundle/playstoreRelease/*.aab
updateFdroidRepo:
stage: deploy
only:
- tags
when: on_success
needs:
- job: buildAndDeployApkStable
artifacts: true
script:
- python3 update_fdroid_index.py
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
size 6342128
-6
View File
@@ -231,10 +231,4 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.4.0') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
}
@@ -789,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body");
else null;
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (captchaConfig.userAgent != null)
_webView.settings.userAgentString = captchaConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let {
_callback = null;
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
@@ -156,6 +157,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -189,6 +191,7 @@ open class JSClient : IPlatformClient {
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
}
private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers));
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf())
val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
}
@@ -87,6 +87,7 @@ class V8Plugin {
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null;
val bridge: PackageBridge;
var isStopped = true;
val onStopped = Event1<V8Plugin>();
@@ -114,7 +115,8 @@ class V8Plugin {
this._clientAuth = clientAuth;
this.config = config;
this._script = script;
withDependency(PackageBridge(this, config));
bridge = PackageBridge(this, config);
withDependency(bridge);
for(pack in config.packages)
withDependency(getPackage(pack)!!);
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
private val _client: ManagedHttpClient
@Transient
private val _clientAuth: ManagedHttpClient
// Set by JSClient after construction to provide access to auth/captcha data
@Transient
var descriptor: SourcePluginDescriptor? = null
override val name: String get() = "Bridge";
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
return "android";
}
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
@V8Property
fun captchaUserAgent(): String? {
return descriptor?.getCaptchaData()?.userAgent
}
@V8Property
fun authUserAgent(): String? {
return descriptor?.getAuth()?.userAgent
}
@V8Property
fun supportedFeatures(): Array<String> {
return arrayOf(
@@ -96,11 +96,15 @@ class LoginFragment : MainFragment() {
else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
webViewClient.onLogin.subscribe { auth ->
_callback?.let {
@@ -16,13 +16,15 @@ class CaptchaWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _captchaConfig: SourcePluginCaptchaConfig;
private val _userAgent: String?;
private var _didNotify = false;
private val _extractor: WebViewRequirementExtractor;
constructor(config: SourcePluginConfig) : super() {
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config;
_captchaConfig = config.captcha!!;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
config.allowUrls,
null,
@@ -34,9 +36,10 @@ class CaptchaWebViewClient : WebViewClient {
Logger.i(TAG, "Captcha [${config.name}]" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.captcha!!.cookiesToFind)}",);
}
constructor(captcha: SourcePluginCaptchaConfig) : super() {
constructor(captcha: SourcePluginCaptchaConfig, userAgent: String? = null) : super() {
_pluginConfig = null;
_captchaConfig = captcha;
_userAgent = userAgent;
_extractor = WebViewRequirementExtractor(
null,
null,
@@ -62,7 +65,8 @@ class CaptchaWebViewClient : WebViewClient {
_didNotify = true;
onCaptchaFinished.emit(SourceCaptchaData(
extracted.cookies,
extracted.headers
extracted.headers,
_userAgent
));
}
@@ -24,23 +24,26 @@ class LoginWebViewClient : WebViewClient {
private val _pluginConfig: SourcePluginConfig?;
private val _authConfig: SourcePluginAuthConfig;
private val _userAgent: String?;
private val _client = ManagedHttpClient();
val onLogin = Event1<SourceAuth>();
val onPageLoaded = Event2<WebView?, String?>()
constructor(config: SourcePluginConfig) : super() {
constructor(config: SourcePluginConfig, userAgent: String? = null) : super() {
_pluginConfig = config;
_authConfig = config.authentication!!;
_userAgent = userAgent;
Logger.i(TAG, "Login [${config.name}]" +
"\nRequired Headers: ${config.authentication.headersToFind?.joinToString(", ")}" +
"\nRequired Domain Headers: ${Serializer.json.encodeToString(config.authentication.domainHeadersToFind)}" +
"\nRequired Cookies: ${Serializer.json.encodeToString(config.authentication.cookiesToFind)}",);
}
constructor(auth: SourcePluginAuthConfig) : super() {
constructor(auth: SourcePluginAuthConfig, userAgent: String? = null) : super() {
_pluginConfig = null;
_authConfig = auth;
_userAgent = userAgent;
}
private val headersFoundMap: HashMap<String, HashMap<String, String>> = hashMapOf();
@@ -192,13 +195,14 @@ class LoginWebViewClient : WebViewClient {
if (urlFound && headersFound && domainHeadersFound && cookiesFound) {
onLogin.emit(SourceAuth(
cookieMap = cookiesFoundMap,
headers = headersFoundMap /*.associate { headerToFind ->
headers = headersFoundMap, /*.associate { headerToFind ->
headerToFind to headersFoundMap.firstNotNullOf { requestHeader ->
if (requestHeader.key.equals(headerToFind, ignoreCase = true))
requestHeader.value
else null;
}
} ?: mapOf()*/
userAgent = _userAgent
));
}
+13 -4
View File
@@ -1,5 +1,13 @@
#!/bin/sh
set -eu
DOCUMENT_ROOT=/var/www/html
MAINT_FILE="$DOCUMENT_ROOT/maintenance.file"
cleanup() {
rm -f "$MAINT_FILE"
}
trap cleanup EXIT INT TERM
# Sign sources
echo "Signing all sources..."
@@ -11,12 +19,12 @@ echo "Building content..."
# Take site offline
echo "Taking site offline..."
touch $DOCUMENT_ROOT/maintenance.file
touch "$MAINT_FILE"
# Swap over the content
echo "Deploying content..."
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab $DOCUMENT_ROOT/app-playstore-release.aab
aws s3 cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab s3://artifacts-grayjay-app/app-playstore-release.aab
cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab \
"$DOCUMENT_ROOT/app-playstore-release.aab"
# Notify Cloudflare to wipe the CDN cache
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
@@ -29,4 +37,5 @@ sleep 30
# Take site back online
echo "Bringing site back online..."
rm $DOCUMENT_ROOT/maintenance.file
rm -f "$MAINT_FILE"
trap - EXIT INT TERM
+70 -44
View File
@@ -1,55 +1,81 @@
#!/bin/sh
DOCUMENT_ROOT=/var/www/html
set -eu
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
r2_cp() {
src="$1"
key="$2"
cache_control="$3"
content_type="$4"
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
AWS_DEFAULT_REGION=auto \
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
--endpoint-url "$R2_ENDPOINT" \
--only-show-errors \
--cache-control "$cache_control" \
--content-type "$content_type"
}
upload_apk_latest_and_versioned() {
src="$1"
filename="$2"
r2_cp "$src" "$VERSION/$filename" \
"public, max-age=31536000, immutable" \
"application/vnd.android.package-archive"
r2_cp "$src" "$filename" \
"no-store" \
"application/vnd.android.package-archive"
}
# Sign sources
echo "Signing all sources..."
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
./gradlew --stacktrace assembleStableRelease
# Take site offline
echo "Taking site offline..."
touch $DOCUMENT_ROOT/maintenance.file
VERSION="$(git describe --tags)"
# Swap over the content
echo "Deploying artifacts to Cloudflare R2..."
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk" "app-x86_64-release.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-arm64-v8a-release.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk" "app-armeabi-v7a-release.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-universal-release.apk" "app-universal-release.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-x86-release.apk" "app-x86-release.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk" "app-release.apk"
tmp_version="$(mktemp)"
printf '%s\n' "$VERSION" > "$tmp_version"
r2_cp "$tmp_version" "$VERSION/version.txt" \
"public, max-age=31536000, immutable" \
"text/plain; charset=utf-8"
r2_cp "$tmp_version" "version.txt" \
"no-store" \
"text/plain; charset=utf-8"
rm -f "$tmp_version"
tmp_changelog="$(mktemp)"
git tag -l --format='%(contents)' "$VERSION" > "$tmp_changelog"
r2_cp "$tmp_changelog" "changelogs/$VERSION" \
"public, max-age=31536000, immutable" \
"text/plain; charset=utf-8"
rm -f "$tmp_changelog"
DOCUMENT_ROOT=/var/www/html
echo "Deploying content..."
cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk $DOCUMENT_ROOT/app-universal-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk $DOCUMENT_ROOT/app-x86-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release.apk
cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk "$DOCUMENT_ROOT/app-x86_64-release.apk"
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-arm64-v8a-release.apk"
cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk "$DOCUMENT_ROOT/app-armeabi-v7a-release.apk"
cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk "$DOCUMENT_ROOT/app-universal-release.apk"
cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk "$DOCUMENT_ROOT/app-x86-release.apk"
cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-release.apk"
VERSION="$(git describe --tags)"
echo "$VERSION" > "$DOCUMENT_ROOT/version.txt"
mkdir -p "$DOCUMENT_ROOT/changelogs"
git tag -l --format='%(contents)' "$VERSION" > "$DOCUMENT_ROOT/changelogs/$VERSION"
VERSION=$(git describe --tags)
echo $VERSION > $DOCUMENT_ROOT/version.txt
mkdir -p $DOCUMENT_ROOT/changelogs
git tag -l --format='%(contents)' $VERSION > $DOCUMENT_ROOT/changelogs/$VERSION
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release.apk
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release.apk
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release.apk
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release.apk
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release.apk
aws s3 cp ./app/build/outputs/apk/stable/release/app-stable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release.apk
VERSION=$(git describe --tags)
echo $VERSION > ./version.txt
git tag -l --format='%(contents)' $VERSION > ./changelog.txt
aws s3 cp ./version.txt s3://artifacts-grayjay-app/version.txt
aws s3 cp ./changelog.txt s3://artifacts-grayjay-app/changelogs/$VERSION
# Notify Cloudflare to wipe the CDN cache
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://releases.grayjay.app/app-x86_64-release.apk", "https://releases.grayjay.app/app-arm64-v8a-release.apk", "https://releases.grayjay.app/app-armeabi-v7a-release.apk", "https://releases.grayjay.app/app-universal-release.apk", "https://releases.grayjay.app/app-x86-release.apk", "https://releases.grayjay.app/app-release.apk", "https://releases.grayjay.app/version.txt"]}'
sleep 30
# Take site back online
echo "Bringing site back online..."
rm $DOCUMENT_ROOT/maintenance.file
echo "Done."
+61 -36
View File
@@ -1,47 +1,72 @@
#!/bin/sh
DOCUMENT_ROOT=/var/www/html
set -eu
R2_ENDPOINT="https://$CF_R2_ACCOUNT_ID.r2.cloudflarestorage.com"
r2_cp() {
src="$1"
key="$2"
cache_control="$3"
content_type="$4"
AWS_ACCESS_KEY_ID="$CF_R2_ACCESS_KEY_ID" \
AWS_SECRET_ACCESS_KEY="$CF_R2_SECRET_ACCESS_KEY" \
AWS_DEFAULT_REGION=auto \
aws s3 cp "$src" "s3://$CF_R2_BUCKET/$key" \
--endpoint-url "$R2_ENDPOINT" \
--only-show-errors \
--cache-control "$cache_control" \
--content-type "$content_type"
}
upload_apk_latest_and_versioned() {
src="$1"
filename="$2"
r2_cp "$src" "$VERSION/$filename" \
"public, max-age=31536000, immutable" \
"application/vnd.android.package-archive"
r2_cp "$src" "$filename" \
"no-store" \
"application/vnd.android.package-archive"
}
# Sign sources
echo "Signing all sources..."
/usr/bin/bash ./sign-all-sources.sh
# Build content
echo "Building content..."
./gradlew --stacktrace assembleUnstableRelease
# Take site offline
echo "Taking site offline..."
touch $DOCUMENT_ROOT/maintenance.file
VERSION="$(git describe --tags)"
# Swap over the content
echo "Deploying unstable artifacts to Cloudflare R2..."
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk" "app-x86_64-release-unstable.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-arm64-v8a-release-unstable.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk" "app-armeabi-v7a-release-unstable.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk" "app-universal-release-unstable.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk" "app-x86-release-unstable.apk"
upload_apk_latest_and_versioned "./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk" "app-release-unstable.apk"
tmp_version="$(mktemp)"
printf '%s\n' "$VERSION" > "$tmp_version"
r2_cp "$tmp_version" "$VERSION/version-unstable.txt" \
"public, max-age=31536000, immutable" \
"text/plain; charset=utf-8"
r2_cp "$tmp_version" "version-unstable.txt" \
"no-store" \
"text/plain; charset=utf-8"
rm -f "$tmp_version"
DOCUMENT_ROOT=/var/www/html
echo "Deploying content..."
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk $DOCUMENT_ROOT/app-x86_64-release-unstable.apk
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-arm64-v8a-release-unstable.apk
cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk $DOCUMENT_ROOT/app-armeabi-v7a-release-unstable.apk
cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk $DOCUMENT_ROOT/app-universal-release-unstable.apk
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk $DOCUMENT_ROOT/app-x86-release-unstable.apk
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk $DOCUMENT_ROOT/app-release-unstable.apk
git describe --tags > $DOCUMENT_ROOT/version-unstable.txt
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk "$DOCUMENT_ROOT/app-x86_64-release-unstable.apk"
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-arm64-v8a-release-unstable.apk"
cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk "$DOCUMENT_ROOT/app-armeabi-v7a-release-unstable.apk"
cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk "$DOCUMENT_ROOT/app-universal-release-unstable.apk"
cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk "$DOCUMENT_ROOT/app-x86-release-unstable.apk"
cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk "$DOCUMENT_ROOT/app-release-unstable.apk"
VERSION="$(git describe --tags)"
echo "$VERSION" > "$DOCUMENT_ROOT/version-unstable.txt"
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk s3://artifacts-grayjay-app/app-x86_64-release-unstable.apk
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-arm64-v8a-release-unstable.apk
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk s3://artifacts-grayjay-app/app-armeabi-v7a-release-unstable.apk
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk s3://artifacts-grayjay-app/app-universal-release-unstable.apk
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk s3://artifacts-grayjay-app/app-x86-release-unstable.apk
aws s3 cp ./app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk s3://artifacts-grayjay-app/app-release-unstable.apk
git describe --tags > ./version-unstable.txt
aws s3 cp ./version-unstable.txt s3://artifacts-grayjay-app/version-unstable.txt
# Notify Cloudflare to wipe the CDN cache
echo "Purging Cloudflare cache for zone $CLOUDFLARE_ZONE_ID..."
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://releases.grayjay.app/app-x86_64-release-unstable.apk", "https://releases.grayjay.app/app-arm64-v8a-release-unstable.apk", "https://releases.grayjay.app/app-armeabi-v7a-release-unstable.apk", "https://releases.grayjay.app/app-universal-release-unstable.apk", "https://releases.grayjay.app/app-x86-release-unstable.apk", "https://releases.grayjay.app/app-release-unstable.apk", "https://releases.grayjay.app/version-unstable.txt"]}'
sleep 30
# Take site back online
echo "Bringing site back online..."
rm $DOCUMENT_ROOT/maintenance.file
echo "Done."
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
import argparse
import os
import sys
import random
import time
import httplib2
import socket
from google_auth_httplib2 import AuthorizedHttp
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from googleapiclient.errors import HttpError
from googleapiclient.http import build_http
SCOPE = "https://www.googleapis.com/auth/androidpublisher"
socket.setdefaulttimeout(30 * 60)
def die(msg: str, code: int = 1):
print(msg, file=sys.stderr)
raise SystemExit(code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sa", required=True, help="Service account JSON file path")
ap.add_argument("--package", required=True, help="ApplicationId / package name")
ap.add_argument("--aab", required=True, help="Path to .aab file")
ap.add_argument("--track", default="internal", help="internal|alpha|beta|production")
ap.add_argument("--status", default="completed", help="draft|inProgress|halted|completed")
ap.add_argument("--name", default=None, help="Release name (defaults to CI_COMMIT_TAG)")
ap.add_argument("--rollout", type=float, default=None, help="For staged rollout: 0 < rollout < 1")
args = ap.parse_args()
if not os.path.isfile(args.sa):
die(f"Missing service account JSON: {args.sa}")
if not os.path.isfile(args.aab):
die(f"Missing AAB: {args.aab}")
release_name = args.name or os.environ.get("CI_COMMIT_TAG")
if not release_name:
die("Missing release name: pass --name or set CI_COMMIT_TAG")
staged = args.status in ("inProgress", "halted")
if staged:
if args.rollout is None:
die("--rollout is required when --status is inProgress or halted")
if not (0.0 < args.rollout < 1.0):
die("--rollout must satisfy 0 < rollout < 1")
else:
args.rollout = None
print(f"Loading service account")
creds = service_account.Credentials.from_service_account_file(
args.sa, scopes=[SCOPE]
)
print(f"Loaded service account")
print(f"Building service")
http = build_http()
authed_http = AuthorizedHttp(creds, http=http)
service = build("androidpublisher", "v3", http=authed_http, cache_discovery=False)
print(f"Built service")
try:
print(f"Creating edit")
edit = service.edits().insert(body={}, packageName=args.package).execute()
edit_id = edit["id"]
UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024
MAX_RETRIES = 8
print(f"Media upload started")
media = MediaFileUpload(
args.aab,
mimetype="application/octet-stream",
resumable=True,
chunksize=UPLOAD_CHUNK_SIZE,
)
request = service.edits().bundles().upload(
packageName=args.package,
editId=edit_id,
media_body=media,
)
response = None
last_pct = -1
attempt = 0
while response is None:
try:
status, response = request.next_chunk(num_retries=3)
attempt = 0 # reset after any successful chunk
if status:
pct = int(status.progress() * 100)
if pct != last_pct:
last_pct = pct
print(f"Upload progress: {pct}%", flush=True)
except HttpError as e:
# Retry transient server-side errors with exponential backoff
code = getattr(getattr(e, "resp", None), "status", None)
if code in (500, 502, 503, 504) and attempt < MAX_RETRIES:
sleep_s = min(60, (2 ** attempt)) + random.random()
print(f"Transient HTTP {code}; retrying in {sleep_s:.1f}s...", flush=True)
time.sleep(sleep_s)
attempt += 1
continue
raise
print("Media upload finished")
bundle = response
version_code = bundle["versionCode"]
release = {
"name": release_name,
"status": args.status,
"versionCodes": [str(version_code)],
}
if args.rollout is not None:
release["userFraction"] = args.rollout
track_body = {"releases": [release]}
print(f"Updating track")
service.edits().tracks().update(
packageName=args.package,
editId=edit_id,
track=args.track,
body=track_body,
).execute()
print(f"Updated track")
print(f"Committing")
service.edits().commit(packageName=args.package, editId=edit_id).execute()
print(f"Committed")
print(f"OK: package={args.package} track={args.track} status={args.status} versionCode={version_code} name={release_name}")
except HttpError as e:
content = e.content.decode("utf-8", errors="replace") if getattr(e, "content", None) else str(e)
die(f"Google API error (HTTP {e.resp.status if e.resp else '??'}):\n{content}")
except Exception as e:
die(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
from __future__ import annotations
import glob
import hashlib
import datetime
import os
import re
import shutil
import subprocess
import sys
import tempfile
from typing import Optional
APK_URL = "https://releases.grayjay.app/app-universal-release.apk"
FDROID_REPO_SSH = "git@gitlab.futo.org:fdroid/repo-v2.git"
FDROID_INDEX_PATH = "apps/Grayjay/index.yml"
UNIVERSAL_APK_GLOB = "app/build/outputs/apk/stable/release/*universal*.apk"
GIT_USER_NAME = "koen"
GIT_USER_EMAIL = "koen@futo.org"
class Fatal(Exception):
pass
def run(cmd: list[str], *, cwd: Optional[str] = None) -> str:
p = subprocess.run(cmd, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if p.returncode != 0:
raise Fatal(f"Command failed ({p.returncode}): {' '.join(cmd)}\n{p.stdout}")
return p.stdout.strip()
def sha256_of_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def pick_universal_apk() -> str:
matches = sorted(glob.glob(UNIVERSAL_APK_GLOB))
if not matches:
raise Fatal(f"No universal APK found via glob: {UNIVERSAL_APK_GLOB}")
for m in matches:
base = os.path.basename(m)
if "app-stable-universal" in base:
return m
return matches[-1]
def get_release_date_today() -> str:
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
def get_version_code_from_tag() -> int:
tag = os.environ.get("CI_COMMIT_TAG", "").strip()
if not tag:
tag = run(["git", "describe", "--tags"]).strip()
m = re.search(r"(\d+)", tag)
if not m:
raise Fatal(f"Could not parse an integer versionCode from tag '{tag}'")
return int(m.group(1))
def update_index_yml(path: str, sha256sum: str, date_str: str, version_code: int) -> None:
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
url_line_idx = None
url_line_indent = ""
for i, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("- url:") and APK_URL in stripped:
url_line_idx = i
url_line_indent = line[: len(line) - len(stripped)]
break
if url_line_idx is None:
raise Fatal(f"Did not find an apk entry with url {APK_URL} in {path}")
def is_url_line_same_level(s: str) -> bool:
st = s.lstrip()
indent = s[: len(s) - len(st)]
return st.startswith("- url:") and indent == url_line_indent
end = len(lines)
for j in range(url_line_idx + 1, len(lines)):
if is_url_line_same_level(lines[j]):
end = j
break
child_indent = url_line_indent + " "
found_sha = found_date = found_vc = False
for j in range(url_line_idx + 1, end):
st = lines[j].lstrip()
indent = lines[j][: len(lines[j]) - len(st)]
if st.startswith("sha256sum:"):
lines[j] = f"{indent}sha256sum: {sha256sum}\n"
found_sha = True
elif st.startswith("date:"):
lines[j] = f"{indent}date: {date_str}\n"
found_date = True
elif st.startswith("version-code:"):
lines[j] = f"{indent}version-code: {version_code}\n"
found_vc = True
insert_pos = url_line_idx + 1
to_insert: list[str] = []
if not found_sha:
to_insert.append(f"{child_indent}sha256sum: {sha256sum}\n")
if not found_date:
to_insert.append(f"{child_indent}date: {date_str}\n")
if not found_vc:
to_insert.append(f"{child_indent}version-code: {version_code}\n")
if to_insert:
lines[insert_pos:insert_pos] = to_insert
with open(path, "w", encoding="utf-8") as f:
f.writelines(lines)
def main() -> int:
version_code = get_version_code_from_tag()
date_str = get_release_date_today()
apk_path = pick_universal_apk()
print(f"Computing sha256 for {apk_path} ...")
sha = sha256_of_file(apk_path)
print(f"sha256: {sha}")
print(f"date: {date_str}")
print(f"version-code: {version_code}")
tmpdir = tempfile.mkdtemp(prefix="fdroid-repo-")
try:
print(f"Cloning {FDROID_REPO_SSH} ...")
run(["git", "clone", "--depth", "1", FDROID_REPO_SSH, tmpdir])
run(["git", "config", "user.name", GIT_USER_NAME], cwd=tmpdir)
run(["git", "config", "user.email", GIT_USER_EMAIL], cwd=tmpdir)
index_path = os.path.join(tmpdir, FDROID_INDEX_PATH)
if not os.path.exists(index_path):
raise Fatal(f"Missing {FDROID_INDEX_PATH} in repo-v2")
update_index_yml(index_path, sha, date_str, version_code)
run(["git", "add", FDROID_INDEX_PATH], cwd=tmpdir)
diff_rc = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=tmpdir).returncode
if diff_rc == 0:
print("No changes to commit.")
return 0
msg = f"Grayjay: update sha/date/version-code to {version_code} ({date_str})"
run(["git", "commit", "-m", msg], cwd=tmpdir)
run(["git", "push"], cwd=tmpdir)
print("Pushed update to fdroid/repo-v2.")
return 0
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__":
try:
raise SystemExit(main())
except Fatal as e:
print(f"ERROR: {e}", file=sys.stderr)
raise SystemExit(2)
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
VENV_DIR="${VENV_DIR:-.venv-playstore}"
if [[ ! -d "$VENV_DIR" ]]; then
python3 -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
python -m pip install --upgrade google-api-python-client google-auth google-auth-httplib2