Compare commits

..

4 Commits

Author SHA1 Message Date
Eric Bailey 58b20276ff Add retry button and relayout to accommodate 2026-01-08 13:11:57 -06:00
Eric Bailey 723b597cf9 Add error state in case of config failure 2026-01-08 12:08:17 -06:00
Eric Bailey 7dacf5916e Clarify some comments 2026-01-08 09:59:09 -06:00
Samuel Newman c2e5a18e42 Unify build concurrency to one build per platform (#9655)
Configure concurrency groups so only one iOS build and one Android build
can run at a time across all workflows. This prevents manual builds from
conflicting with automatic builds triggered by fingerprint changes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 07:35:13 -08:00
13 changed files with 120 additions and 147 deletions
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
concurrency:
group: android-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
+3
View File
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
concurrency:
group: ios-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -157,8 +157,8 @@ jobs:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
group: ios-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -262,7 +262,7 @@ jobs:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
group: android-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
-116
View File
@@ -18,119 +18,3 @@ index 0000000..3b5b864
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
+13
View File
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
}
})
}
export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`)
const res = await getConfig()
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
configQueryKey,
res,
)
return res
}
export function useConfigQuery() {
return useQuery(
{
@@ -146,6 +155,10 @@ export function useConfigQuery() {
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
*/
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
/**
* N.B. if prefetch failed above, we'll have no `initialData`, and this
* query will run on startup.
*/
initialData: getConfigFromCache(),
initialDataUpdatedAt: () =>
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
+9 -2
View File
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
access: AgeAssuranceAccess.Safe,
}
// should never happen, but need to guard
/**
* This can happen if the prefetch fails (such as due to network issues).
* The query handler will try it again, but if it continues to fail, of
* course we won't have config.
*
* In this case, fail open to avoid blocking users.
*/
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Unknown,
access: AgeAssuranceAccess.Safe,
error: 'config',
}
}
+1
View File
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
}
export function parseStatusFromString(raw: string) {
@@ -8,6 +8,7 @@ import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {
AgeAssuranceInitDialog,
useDialogControl,
@@ -27,6 +28,13 @@ import {useDeviceGeolocationApi} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return (
<View style={style}>
<AgeAssuranceConfigUnavailableError />
</View>
)
}
return <Inner style={style} />
}
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
@@ -19,6 +20,9 @@ export function AgeAssuranceAdmonition({
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return <AgeAssuranceConfigUnavailableError style={style} />
}
return (
<Inner style={style} control={control}>
@@ -23,6 +23,7 @@ export function useInternalState() {
const visible = useMemo(() => {
if (aa.state.access === aa.Access.Full) return false
if (aa.state.lastInitiatedAt) return false
if (aa.state.error === 'config') return false
if (hidden) return false
if (nux && nux.completed) return false
return true
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -26,33 +27,37 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
return (
<View style={style}>
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
{aa.state.error === 'config' ? (
<AgeAssuranceConfigUnavailableError />
) : (
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
)}
</View>
)
}
@@ -0,0 +1,37 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ViewStyleProp} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {refetchConfig} from '#/ageAssurance/data'
export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
const {_} = useLingui()
return (
<Admonition.Outer type="error" style={props.style}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
We were unable to load the age assurance configuration for your
region, probably due to a network error. Some content and features
may be unavailable temporarily. Please try again later.
</Trans>
</Admonition.Text>
</Admonition.Content>
<Admonition.Button
color="negative_subtle"
label={_(msg`Retry`)}
onPress={() => refetchConfig().catch(() => {})}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} />
</Admonition.Button>
</Admonition.Row>
</Admonition.Outer>
)
}
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
@@ -44,6 +45,12 @@ export function AgeRestrictedScreen({
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg]}>
{aa.state.error === 'config' && (
<View style={[a.pb_lg]}>
<AgeAssuranceConfigUnavailableError />
</View>
)}
<View style={[a.align_start, a.pb_lg]}>
<AgeAssuranceBadge />
</View>