Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Kelvin 2024-12-10 22:35:46 +01:00
commit 7cdded8fd7
11 changed files with 259 additions and 115 deletions

View File

@ -52,7 +52,7 @@
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleTask" android:launchMode="singleInstance"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true"> android:supportsPictureInPicture="true">

View File

@ -6,6 +6,7 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@ -57,11 +58,21 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_editComment = findViewById(R.id.edit_comment); _editComment = findViewById(R.id.edit_comment);
_textCharacterCount = findViewById(R.id.character_count); _textCharacterCount = findViewById(R.id.character_count);
_textCharacterCountMax = findViewById(R.id.character_count_max); _textCharacterCountMax = findViewById(R.id.character_count_max);
setCanceledOnTouchOutside(false)
setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
handleCloseAttempt()
true
} else {
false
}
}
_editComment.addTextChangedListener(object : TextWatcher { _editComment.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, c: Int) {
val count = s?.length ?: 0;
_textCharacterCount.text = count.toString(); _textCharacterCount.text = count.toString();
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) { if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
@ -79,10 +90,13 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager; _inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_buttonCancel.setOnClickListener { _buttonCancel.setOnClickListener {
clearFocus(); handleCloseAttempt()
dismiss();
}; };
setOnCancelListener {
handleCloseAttempt()
}
_buttonCreate.setOnClickListener { _buttonCreate.setOnClickListener {
clearFocus(); clearFocus();
@ -134,6 +148,22 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
focus(); focus();
} }
private fun handleCloseAttempt() {
if (_editComment.text.isEmpty()) {
clearFocus()
dismiss()
} else {
UIDialogs.showConfirmationDialog(
context,
context.resources.getString(R.string.not_empty_close),
action = {
clearFocus()
dismiss()
}
)
}
}
private fun focus() { private fun focus() {
_editComment.requestFocus(); _editComment.requestFocus();
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);

View File

@ -8,6 +8,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.EditText import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner import android.widget.Spinner
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -25,11 +26,20 @@ class CreatorsFragment : MainFragment() {
private var _overlayContainer: FrameLayout? = null; private var _overlayContainer: FrameLayout? = null;
private var _containerSearch: FrameLayout? = null; private var _containerSearch: FrameLayout? = null;
private var _editSearch: EditText? = null; private var _editSearch: EditText? = null;
private var _buttonClearSearch: ImageButton? = null
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
_containerSearch = view.findViewById(R.id.container_search); _containerSearch = view.findViewById(R.id.container_search);
_editSearch = view.findViewById(R.id.edit_search); val editSearch: EditText = view.findViewById(R.id.edit_search);
val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search)
_editSearch = editSearch
_buttonClearSearch = buttonClearSearch
buttonClearSearch.setOnClickListener {
editSearch.text.clear()
editSearch.requestFocus()
_buttonClearSearch?.visibility = View.INVISIBLE;
}
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
@ -51,7 +61,12 @@ class CreatorsFragment : MainFragment() {
_spinnerSortBy = spinnerSortBy; _spinnerSortBy = spinnerSortBy;
_editSearch?.addTextChangedListener { _editSearch?.addTextChangedListener {
adapter.query = it.toString(); adapter.query = it.toString()
if (it?.isEmpty() == true) {
_buttonClearSearch?.visibility = View.INVISIBLE
} else {
_buttonClearSearch?.visibility = View.VISIBLE
}
} }
val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions); val recyclerView = view.findViewById<RecyclerView>(R.id.recycler_subscriptions);

View File

@ -1799,8 +1799,13 @@ class VideoDetailView : ConstraintLayout {
private fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean){ private fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean){
Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)") Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)")
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource)) if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource)) {
UIDialogs.toast(context, context.getString(R.string.offline_playback), false); Logger.i(TAG, "Time since last offline playback toast: " + (System.currentTimeMillis() - _lastOfflinePlaybackToastTime).toString())
if (System.currentTimeMillis() - _lastOfflinePlaybackToastTime > 5000) {
UIDialogs.toast(context, context.getString(R.string.offline_playback), false);
_lastOfflinePlaybackToastTime = System.currentTimeMillis()
}
}
//If LiveStream, set to end //If LiveStream, set to end
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) { if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
if (video?.isLive == true) { if (video?.isLive == true) {
@ -2379,8 +2384,8 @@ class VideoDetailView : ConstraintLayout {
} }
fun isLandscapeVideo(): Boolean? { fun isLandscapeVideo(): Boolean? {
var videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width val videoSourceWidth = _player.exoPlayer?.player?.videoSize?.width
var videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height val videoSourceHeight = _player.exoPlayer?.player?.videoSize?.height
return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){ return if (videoSourceWidth == null || videoSourceHeight == null || videoSourceWidth == 0 || videoSourceHeight == 0){
null null
@ -3030,8 +3035,6 @@ class VideoDetailView : ConstraintLayout {
const val TAG_MORE = "MORE"; const val TAG_MORE = "MORE";
private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons"); private val _buttonPinStore = FragmentedStorage.get<StringArrayStorage>("videoPinnedButtons");
private var _lastOfflinePlaybackToastTime: Long = 0
} }
} }

View File

@ -12,70 +12,109 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex import com.futo.platformplayer.timestampRegex
import com.futo.platformplayer.views.behavior.NonScrollingTextView
import com.futo.platformplayer.views.behavior.NonScrollingTextView.Companion
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class PlatformLinkMovementMethod : LinkMovementMethod { class PlatformLinkMovementMethod(private val _context: Context) : LinkMovementMethod() {
private val _context: Context;
constructor(context: Context) : super() { private var pressedLinks: Array<URLSpan>? = null
_context = context; private var linkPressed = false
} private var downX = 0f
private var downY = 0f
private val touchSlop = 20
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.action; val action = event.actionMasked
Logger.i(TAG, "onTouchEvent (action = $action)")
if (action == MotionEvent.ACTION_UP) {
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX;
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY;
val layout = widget.layout; when (action) {
val line = layout.getLineForVertical(y); MotionEvent.ACTION_DOWN -> {
val off = layout.getOffsetForHorizontal(line, x.toFloat()); val links = findLinksAtTouchPosition(widget, buffer, event)
val links = buffer.getSpans(off, off, URLSpan::class.java); if (links.isNotEmpty()) {
pressedLinks = links
linkPressed = true
downX = event.x
downY = event.y
widget.parent?.requestDisallowInterceptTouchEvent(true)
return true
} else {
linkPressed = false
pressedLinks = null
}
}
if (links.isNotEmpty()) { MotionEvent.ACTION_MOVE -> {
runBlocking { if (linkPressed) {
for (link in links) { val dx = event.x - downX
Logger.i(TAG) { "Link clicked '${link.url}'." }; val dy = event.y - downY
if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) {
linkPressed = false
pressedLinks = null
widget.parent?.requestDisallowInterceptTouchEvent(false)
return false
}
return true
}
}
if (_context is MainActivity) { MotionEvent.ACTION_UP -> {
if (_context.handleUrl(link.url)) { if (linkPressed && pressedLinks != null) {
continue; val dx = event.x - downX
} val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop) {
runBlocking {
for (link in pressedLinks!!) {
Logger.i(TAG) { "Link clicked '${link.url}'." }
if (timestampRegex.matches(link.url)) { if (_context is MainActivity) {
val tokens = link.url.split(':'); if (_context.handleUrl(link.url)) continue
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
var time_s = -1L; if (time_s != -1L) {
if (tokens.size == 2) { MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
time_s = tokens[0].toLong() * 60 + tokens[1].toLong(); continue
} else if (tokens.size == 3) { }
time_s = }
tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
} }
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} }
} }
pressedLinks = null
linkPressed = false
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url))); return true
} else {
pressedLinks = null
linkPressed = false
} }
} }
}
return true; MotionEvent.ACTION_CANCEL -> {
linkPressed = false
pressedLinks = null
} }
} }
return super.onTouchEvent(widget, buffer, event); return super.onTouchEvent(widget, buffer, event)
}
private fun findLinksAtTouchPosition(widget: TextView, buffer: Spannable, event: MotionEvent): Array<URLSpan> {
val x = (event.x - widget.totalPaddingLeft + widget.scrollX).toInt()
val y = (event.y - widget.totalPaddingTop + widget.scrollY).toInt()
val layout = widget.layout ?: return emptyArray()
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
return buffer.getSpans(off, off, URLSpan::class.java)
} }
companion object { companion object {
val TAG = "PlatformLinkMovementMethod"; const val TAG = "PlatformLinkMovementMethod"
} }
} }

View File

@ -112,18 +112,23 @@ class StateSync {
Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})")
_thread = Thread { _thread = Thread {
val serverSocket = ServerSocket(PORT) try {
_serverSocket = serverSocket val serverSocket = ServerSocket(PORT)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${PORT} (TCP)") Log.i(TAG, "Running on port ${PORT} (TCP)")
while (_started) { while (_started) {
val socket = serverSocket.accept() val socket = serverSocket.accept()
val session = createSocketSession(socket, true) { session, socketSession -> val session = createSocketSession(socket, true) { session, socketSession ->
}
session.startAsResponder()
} }
} catch (e: Throwable) {
session.startAsResponder() Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e)
UIDialogs.toast("Failed to start sync, port in use")
} }
}.apply { start() } }.apply { start() }

View File

@ -16,73 +16,113 @@ import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView { class NonScrollingTextView : androidx.appcompat.widget.AppCompatTextView {
private var _lastTouchedLinks: Array<URLSpan>? = null
private var downX = 0f
private var downY = 0f
private var linkPressed = false
private val touchSlop = 20
constructor(context: Context) : super(context) {} constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {} constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {} constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
override fun scrollTo(x: Int, y: Int) { override fun scrollTo(x: Int, y: Int) {
//do nothing // do nothing
} }
override fun onTouchEvent(event: MotionEvent?): Boolean { override fun onTouchEvent(event: MotionEvent?): Boolean {
val action = event?.action val action = event?.actionMasked
Logger.i(TAG, "onTouchEvent (action = $action)"); if (event == null) return super.onTouchEvent(event)
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { when (action) {
val x = event.x.toInt() MotionEvent.ACTION_DOWN -> {
val y = event.y.toInt() val x = event.x.toInt()
val y = event.y.toInt()
val layout: Layout? = this.layout val layout: Layout? = this.layout
if (layout != null) { if (layout != null && this.text is Spannable) {
val line = layout.getLineForVertical(y) val offset = layout.getOffsetForHorizontal(layout.getLineForVertical(y), x.toFloat())
val offset = layout.getOffsetForHorizontal(line, x.toFloat()) val text = this.text as Spannable
val text = this.text
if (text is Spannable) {
val links = text.getSpans(offset, offset, URLSpan::class.java) val links = text.getSpans(offset, offset, URLSpan::class.java)
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
runBlocking { parent?.requestDisallowInterceptTouchEvent(true)
for (link in links) { _lastTouchedLinks = links
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }; downX = event.x
downY = event.y
val c = context; linkPressed = true
if (c is MainActivity) {
if (c.handleUrl(link.url)) {
continue;
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
}
}
}
return true return true
} else {
linkPressed = false
_lastTouchedLinks = null
} }
} }
} }
MotionEvent.ACTION_MOVE -> {
if (linkPressed) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) {
linkPressed = false
_lastTouchedLinks = null
parent?.requestDisallowInterceptTouchEvent(false)
return false
}
return true
}
}
MotionEvent.ACTION_UP -> {
if (linkPressed && _lastTouchedLinks != null) {
val dx = event.x - downX
val dy = event.y - downY
if (Math.abs(dx) <= touchSlop && Math.abs(dy) <= touchSlop) {
runBlocking {
for (link in _lastTouchedLinks!!) {
Logger.i(PlatformLinkMovementMethod.TAG) { "Link clicked '${link.url}'." }
val c = context
if (c is MainActivity) {
if (c.handleUrl(link.url)) continue
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':')
var time_s = -1L
when (tokens.size) {
2 -> time_s = tokens[0].toLong() * 60 + tokens[1].toLong()
3 -> time_s = tokens[0].toLong() * 3600 +
tokens[1].toLong() * 60 +
tokens[2].toLong()
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000)
continue
}
}
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
} else {
c.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)))
}
}
}
_lastTouchedLinks = null
linkPressed = false
return true
} else {
linkPressed = false
_lastTouchedLinks = null
}
}
}
MotionEvent.ACTION_CANCEL -> {
linkPressed = false
_lastTouchedLinks = null
}
} }
super.onTouchEvent(event) return super.onTouchEvent(event)
return false
} }
companion object { companion object {
private const val TAG = "NonScrollingTextView" private const val TAG = "NonScrollingTextView"
} }
} }

View File

@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IAsyncPager
@ -267,9 +268,13 @@ class CommentsList : ConstraintLayout {
} }
fun replaceComment(c: PolycentricPlatformComment, newComment: PolycentricPlatformComment) { fun replaceComment(c: PolycentricPlatformComment, newComment: PolycentricPlatformComment) {
val index = _comments.indexOf(c); val index = _comments.indexOfFirst { it == c || (it is LazyComment && it.getUnderlyingComment() == c) };
_comments[index] = newComment; if (index >= 0) {
_adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index)); _comments[index] = newComment;
_adapterComments.notifyItemChanged(_adapterComments.childToParentPosition(index));
} else {
Logger.w(TAG, "Parent comment not found")
}
} }
companion object { companion object {

View File

@ -592,6 +592,11 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun setFullScreen(fullScreen: Boolean) { fun setFullScreen(fullScreen: Boolean) {
// prevent fullscreen before the video has loaded to make sure we know whether it's a vertical or horizontal video
if(exoPlayer?.player?.videoSize?.height ?: 0 == 0 && fullScreen){
return
}
updateRotateLock() updateRotateLock()
if (isFullScreen == fullScreen) { if (isFullScreen == fullScreen) {

View File

@ -199,6 +199,7 @@
<string name="previous">Previous</string> <string name="previous">Previous</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="comment">Comment</string> <string name="comment">Comment</string>
<string name="not_empty_close">Comment is not empty, close anyway?</string>
<string name="str_import">Import</string> <string name="str_import">Import</string>
<string name="my_playlist_name">My Playlist Name</string> <string name="my_playlist_name">My Playlist Name</string>
<string name="do_you_want_to_import_this_store">Do you want to import this store?</string> <string name="do_you_want_to_import_this_store">Do you want to import this store?</string>

View File

@ -15,6 +15,7 @@ touch $DOCUMENT_ROOT/maintenance.file
# Swap over the content # Swap over the content
echo "Deploying 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 aws s3 cp ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab s3://artifacts-grayjay-app/app-playstore-release.aab
# Notify Cloudflare to wipe the CDN cache # Notify Cloudflare to wipe the CDN cache