mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 |
+39
-19
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
|
||||
size 6342128
|
||||
@@ -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)
|
||||
}
|
||||
+8
-6
@@ -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(
|
||||
|
||||
+6
-2
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Submodule app/src/stable/assets/sources/kick updated: 5cae761620...bea721edc3
Submodule app/src/stable/assets/sources/patreon updated: 6b3b6d25e5...52154f36c2
Submodule app/src/stable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/stable/assets/sources/youtube updated: 47c5b3b894...fb90a44f83
Submodule app/src/unstable/assets/sources/kick updated: 5cae761620...bea721edc3
Submodule app/src/unstable/assets/sources/patreon updated: 6b3b6d25e5...52154f36c2
Submodule app/src/unstable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/unstable/assets/sources/youtube updated: 47c5b3b894...fb90a44f83
@@ -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
@@ -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
@@ -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."
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user