mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-20 06:52:35 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 |
+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(
|
||||
|
||||
@@ -2,16 +2,21 @@ 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
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.collection.emptyLongSet
|
||||
import androidx.webkit.ScriptHandler
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.caoccao.javet.annotations.V8Function
|
||||
import com.caoccao.javet.utils.JavetResourceUtils
|
||||
import com.caoccao.javet.values.reference.V8ValueFunction
|
||||
@@ -27,18 +32,27 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
@@ -52,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)
|
||||
@@ -61,66 +73,181 @@ class PackageBrowser: V8Package {
|
||||
return _browser!!;
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var _userAgent: String = ""
|
||||
private val http = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
|
||||
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
|
||||
|
||||
}
|
||||
@V8Function
|
||||
fun initialize() {
|
||||
if(_browser == null){
|
||||
StateApp.instance.scope.launch(Dispatchers.Main) {
|
||||
_browser = WebView(StateApp.instance.contextOrNull ?: return@launch);
|
||||
_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 onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
if (_browser != null) return
|
||||
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
// Best-effort fallback. Not equivalent, but as early as WebView exposes.
|
||||
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||
for (s in scripts) {
|
||||
try { view?.evaluateJavascript(s, null) } catch (_: Throwable) {}
|
||||
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
|
||||
|
||||
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 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
|
||||
|
||||
val scripts = _pageLoadScriptsFallback.values.toList()
|
||||
if (scripts.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
|
||||
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
|
||||
|
||||
val okReq = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("User-Agent", ua)
|
||||
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
|
||||
.build()
|
||||
|
||||
http.newCall(okReq).execute().use { resp ->
|
||||
val code = resp.code
|
||||
val reason = resp.message.ifBlank { "OK" }
|
||||
if (code in 300..399) return null
|
||||
|
||||
val contentType = resp.header("Content-Type") ?: ""
|
||||
val isHtml =
|
||||
contentType.startsWith("text/html", ignoreCase = true) ||
|
||||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
|
||||
|
||||
if (!isHtml) return null
|
||||
|
||||
val bodyBytes = resp.body.bytes()
|
||||
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
|
||||
val html = bodyBytes.toString(charset)
|
||||
|
||||
val cspHeader = resp.header("Content-Security-Policy")
|
||||
?: resp.header("Content-Security-Policy-Report-Only")
|
||||
|
||||
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) {}
|
||||
}
|
||||
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() {
|
||||
@@ -163,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();
|
||||
@@ -248,9 +388,8 @@ class PackageBrowser: V8Package {
|
||||
require(js.isNotBlank()) { "Script must be non-empty." }
|
||||
|
||||
val id = UUID.randomUUID().toString()
|
||||
|
||||
onMainBlocking {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
||||
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
|
||||
_pageLoadScriptRefs[id] = ref
|
||||
} else {
|
||||
@@ -302,6 +441,29 @@ class PackageBrowser: V8Package {
|
||||
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
|
||||
}
|
||||
|
||||
private fun charsetFromContentType(ct: String): Charset? {
|
||||
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
|
||||
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
|
||||
return runCatching { Charset.forName(name) }.getOrNull()
|
||||
}
|
||||
|
||||
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) {
|
||||
val i = head.range.last + 1
|
||||
return buildString(html.length + tag.length + 8) {
|
||||
append(html, 0, i)
|
||||
append('\n')
|
||||
append(tag)
|
||||
append(html, i, html.length)
|
||||
}
|
||||
}
|
||||
return tag + html
|
||||
}
|
||||
|
||||
private fun <T> onMainBlocking(block: () -> T): T {
|
||||
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
@@ -310,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("&", "&").replace("\"", """)
|
||||
|
||||
@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\""
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+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: 6880b30b71...52154f36c2
Submodule app/src/stable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/stable/assets/sources/youtube updated: ffd1b535d0...fb90a44f83
Submodule app/src/unstable/assets/sources/kick updated: 5cae761620...bea721edc3
Submodule app/src/unstable/assets/sources/patreon updated: 6880b30b71...52154f36c2
Submodule app/src/unstable/assets/sources/peertube updated: 21dcf4bef5...7b52405ad0
Submodule app/src/unstable/assets/sources/youtube updated: ffd1b535d0...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