Compare commits

...

10 Commits

Author SHA1 Message Date
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
Koen J fa1954ceef Fixes. 2026-02-16 11:35:29 +01:00
Koen J 13aa49726a Improved WaitTillLoaded. 2026-02-15 14:46:03 +01:00
Koen J 20bab7d056 Updated submodules. 2026-02-15 11:34:25 +01:00
Koen J cbf7ca0181 Fixes to make it less detectable. 2026-02-15 11:26:10 +01:00
22 changed files with 796 additions and 241 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
@@ -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(
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine.packages
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
@@ -33,6 +34,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -40,6 +42,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
import androidx.core.net.toUri
class PackageBrowser: V8Package {
val useAddDocumentStartJavaScript = true
@@ -47,6 +50,9 @@ class PackageBrowser: V8Package {
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Volatile private var _loadToken: String? = null
@Volatile private var _expectedMainUrl: String? = null
private val _json = Json { };
@Transient
@@ -60,8 +66,6 @@ class PackageBrowser: V8Package {
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private val _interop = JSInterop(this);
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
@@ -81,112 +85,169 @@ class PackageBrowser: V8Package {
}
@V8Function
fun initialize() {
if(_browser == null){
StateApp.instance.scope.launch(Dispatchers.Main) {
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
if (_browser != null) return
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
onMainBlocking {
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
if (!isHtml) return null
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
if (!isHtml) return null
val injected = injectIntoHead(html, scripts.joinToString("\n"))
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
try { cookieMgr.flush() } catch (_: Throwable) {}
val cspHeader = resp.header("Content-Security-Policy")
?: resp.header("Content-Security-Policy-Report-Only")
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
} catch (_: Throwable) {
null
try { cookieMgr.flush() } catch (_: Throwable) {}
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
_readySemaphore?.release();
_readySemaphore = null;
Logger.i("PackageBrowser", "Browser loaded");
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
} catch (_: Throwable) {
null
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if(consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val msg = "Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", msg)
}
else {
val msg = ("Browser Log:" + consoleMessage?.message());
Logger.e("PackageBrowser", msg);
if(_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", msg);
}
return super.onConsoleMessage(consoleMessage)
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
releaseReadyIfCurrent(url)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
releaseReadyIfCurrent(url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val raw = consoleMessage?.message().orEmpty()
val normalized = raw.trim().let { s ->
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
s.substring(1, s.length - 1)
} else s
}
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
if (handleConsoleBridgeMessage(payload)) return true
}
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val emsg = "Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", emsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(StateDeveloper.instance.currentDevID ?: "", emsg)
} else {
val imsg = "Browser Log:${consoleMessage?.message()}"
Logger.i("PackageBrowser", imsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", imsg)
}
return super.onConsoleMessage(consoleMessage)
}
_browser?.addJavascriptInterface(_interop, "__GJ");
}
return;
}
val bootstrap = """
(() => {
try {
if (window.__GJ) return;
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
const emit = (obj) => {
try {
console.info(PREFIX + JSON.stringify(obj));
} catch (_) {}
};
Object.defineProperty(window, "__GJ", {
value: {
callback: (id, result) => {
try {
const r = (typeof result === "string")
? result
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
emit({ t: "cb", id: String(id), result: r });
} catch (_) {}
},
log: (msg) => {
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
}
},
enumerable: false,
configurable: false,
writable: false
});
} catch (_) {}
})();
""".trimIndent()
addScriptOnLoad(bootstrap)
}
@V8Function
fun deinitialize() {
@@ -229,15 +290,28 @@ class PackageBrowser: V8Package {
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [${url}]");
_readySemaphore = Semaphore(1, 1);
Logger.i("PackageBrowser", "Browser loading url [$url]")
val token = UUID.randomUUID().toString()
_loadToken = token
_expectedMainUrl = url
_readySemaphore = Semaphore(1, acquiredPermits = 1)
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
browser.loadUrl(url);
} catch(ex: Throwable) {}
try { browser.loadUrl(url) }
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
}
}
private fun releaseReadyIfCurrent(url: String?) {
if (url == null) return
val expected = _expectedMainUrl
if (url.trimEnd('/') != expected?.trimEnd('/')) return
_readySemaphore?.release()
_readySemaphore = null
_expectedMainUrl = null
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
@@ -373,8 +447,9 @@ class PackageBrowser: V8Package {
return runCatching { Charset.forName(name) }.getOrNull()
}
private fun injectIntoHead(html: String, js: String): String {
val tag = "<script>\n$js\n</script>\n"
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
val tag = "<script$nonceAttr>\n$js\n</script>\n"
val head = Regex("(?i)<head[^>]*>").find(html)
if (head != null) {
@@ -386,7 +461,6 @@ class PackageBrowser: V8Package {
append(html, i, html.length)
}
}
return tag + html
}
@@ -398,23 +472,66 @@ class PackageBrowser: V8Package {
}
}
class JSInterop(private val pack: PackageBrowser) {
private fun extractNonceFromCsp(csp: String?): String? {
if (csp.isNullOrBlank()) return null
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
return m.groupValues[1]
}
@JavascriptInterface
fun callback(id: String, result: String) {
Logger.i("PackageBrowser", "Browser Callback [${id}]: ${result}");
val callback = synchronized(pack._callbacks) { pack._callbacks.remove(id); };
if(callback != null) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
callback.invoke(result);
}
private fun extractNonceFromHtml(html: String): String? {
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
return m?.groupValues?.get(1)
}
private fun escapeHtmlAttr(s: String): String =
s.replace("&", "&amp;").replace("\"", "&quot;")
@Serializable
private data class ConsoleBridgeMsg(
val t: String,
val id: String? = null,
val result: String? = null,
val msg: String? = null
)
private fun handleConsoleBridgeMessage(payload: String): Boolean {
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
?: return false
when (parsed.t) {
"cb" -> {
val id = parsed.id ?: return true
val res = parsed.result
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { cb.invoke(res) }
return true
}
"log" -> {
val text = parsed.msg.orEmpty()
Logger.i("PackageBrowser", "Browser Log: $text")
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
}
return true
}
else -> return true
}
}
@JavascriptInterface
fun log(msg: String) {
Logger.i("PackageBrowser", "Log: " + msg);
private companion object {
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
private fun String.quoteForJs(): String {
val s = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$s\""
}
}
}
@@ -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
+56 -43
View File
@@ -1,55 +1,68 @@
#!/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 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
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"
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
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"
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
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"
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."
+50 -36
View File
@@ -1,47 +1,61 @@
#!/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 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
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"
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
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"
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