Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
- name: Android platform 37
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37.0" "build-tools;37.0.0" > /dev/null
- name: Unit tests
run: ./gradlew testGenericDebugUnitTest testGplayDebugUnitTest
run: ./gradlew alohaUnitTests

screenshot:
name: Screenshot comparison
Expand All @@ -80,7 +80,7 @@ jobs:
- name: Android platform 37
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-37.0" "build-tools;37.0.0" > /dev/null
- name: Compare screenshots
run: ./gradlew verifyRoborazziGenericDebug
run: ./gradlew alohaScreenshotTests
- name: Keep the differences
if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
Expand Down
23 changes: 21 additions & 2 deletions .github/workflows/ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
# the emulator's userdata partition needs about 7.4 GB, more than the runner has left after
# the release builds; these preinstalled toolchains are never used here
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL
df -h /
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
Expand All @@ -40,12 +46,25 @@ jobs:
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- name: Release builds signed with the debug key for the emulator
run: ./gradlew assembleGenericRelease assembleGplayRelease -Paloha.signReleaseWithDebugKey=true
# built before the emulator starts, so the connected run below only installs and measures:
# R8 and a running emulator together exhaust the runner's memory
- name: Benchmark builds
run: ./gradlew :app:assembleGenericBenchmarkRelease :benchmark:assembleGenericBenchmarkRelease
- uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 36
arch: x86_64
target: google_apis
script: |
adb install -r app/build/outputs/apk/generic/release/*.apk && "$HOME/.maestro/bin/maestro" test .maestro
adb install -r app/build/outputs/apk/gplay/release/*.apk && "$HOME/.maestro/bin/maestro" test .maestro
adb install -r app/build/outputs/apk/generic/release/*.apk
"$HOME/.maestro/bin/maestro" test .maestro || { mkdir -p build/maestro && adb exec-out screencap -p > build/maestro/generic.png; adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml build/maestro/generic.xml; exit 1; }
adb install -r app/build/outputs/apk/gplay/release/*.apk
"$HOME/.maestro/bin/maestro" test .maestro || { mkdir -p build/maestro && adb exec-out screencap -p > build/maestro/gplay.png; adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml build/maestro/gplay.xml; exit 1; }
./gradlew :benchmark:connectedGenericBenchmarkReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR
# the screen and its view hierarchy when a flow failed, to see what the assertion saw
- if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: maestro-debug
path: build/maestro
if-no-files-found: ignore
3 changes: 1 addition & 2 deletions .github/workflows/verification-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ jobs:
run: >
./gradlew --write-verification-metadata sha256,pgp --export-keys
assembleGenericDebug assembleGplayDebug assembleGenericRelease assembleGplayRelease :benchmark:assemble
detekt ktlintCheck lint alohaArchitectureCheck testGenericDebugUnitTest testGplayDebugUnitTest
verifyRoborazziGenericDebug
detekt ktlintCheck lint alohaArchitectureCheck alohaUnitTests alohaScreenshotTests
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: verification-metadata
Expand Down
38 changes: 30 additions & 8 deletions .maestro/smoke.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
# SPDX-FileCopyrightText: 2026 Aloha Social contributors
# SPDX-License-Identifier: MIT
#
# Release-build smoke: the app starts past R8 and the navigation keys
# survive serialisation. Grows with the app: sign-in against the mock, timeline.
# Release-build smoke: the app starts past R8, and the accepted terms survive
# a restart, so DataStore and the navigation keys work after shrinking.
# Grows with the app: sign-in against the mock, timeline.
appId: social.aloha.android
---
- launchApp:
clearState: true
- assertVisible: "Home"
- tapOn: "Photos"
- assertVisible: "Photos"
- tapOn: "Notifications"
- assertVisible: "Notifications"
# a freshly booted CI emulator often shows "System UI isn't responding" over the app; wait it out
# (text selectors match the whole text, so the pattern needs its wildcards)
- retry:
maxRetries: 3
commands:
- runFlow:
when:
visible: ".*isn't responding.*"
commands:
- tapOn: "Wait"
- extendedWaitUntil:
visible: "Before you start"
timeout: 20000
- tapOn: "Agree and continue"
- assertVisible: "Add account"
- stopApp
- launchApp
- assertVisible: "Home"
- retry:
maxRetries: 3
commands:
- runFlow:
when:
visible: ".*isn't responding.*"
commands:
- tapOn: "Wait"
- extendedWaitUntil:
visible: "Add account"
timeout: 20000
- assertNotVisible: "Before you start"
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Aloha Social contributors"
SPDX-PackageDownloadLocation = "https://github.com/AlohaSocial/Android"

[[annotations]]
path = ["**/*.md", "LICENSE", "docs/**", "**/src/test/screenshots/**", "config/**/*.json", ".maestro/**"]
path = ["**/*.md", "LICENSE", "docs/**", "**/src/test/screenshots/**", "config/**/*.json", ".maestro/**", "core/testing/src/main/resources/fixtures/**", "core/database/schemas/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Aloha Social contributors"
SPDX-License-Identifier = "MIT"
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
implementation(project(":core:platform"))
implementation(project(":core:data"))
implementation(project(":core:network"))
implementation(project(":feature:signin"))
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.activity.compose)
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,34 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- The OAuth callback: exported so the browser can deliver it, draws nothing, finishes at once. -->
<activity
android:name=".OAuthRedirectActivity"
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<!-- The verified App Link: only this app can receive a code sent here. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="aloha.social" />
<data android:path="/oauth/callback" />
</intent-filter>
<!-- The fallback where link verification failed; the server appends a slash, so any path. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="alohasocial" />
<data android:host="oauth-callback" />
</intent-filter>
</activity>
</application>
Expand Down
72 changes: 72 additions & 0 deletions app/src/main/kotlin/social/aloha/android/AlohaRoot.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 Aloha Social contributors
// SPDX-License-Identifier: MIT

package social.aloha.android

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import social.aloha.core.designsystem.AlohaPreviews
import social.aloha.core.designsystem.AlohaSpacing
import social.aloha.core.designsystem.AlohaTheme
import social.aloha.feature.signin.SignInEntry

/** Sign-in until an account exists, the shell afterwards. */
@Composable
fun AlohaRoot(viewModel: AppViewModel = hiltViewModel()) {
val session by viewModel.session.collectAsStateWithLifecycle()
when (val current = session) {
AppSession.Loading -> Unit

AppSession.SigningIn -> SignInEntry()

is AppSession.SignedIn -> Column {
if (current.needsReauth) ReauthBanner(current.handle, onSignInAgain = viewModel::signInAgain)
AlohaApp()
}
}
}

/** The server refused the token: nothing is lost, and one tap starts a new sign-in. */
@Composable
internal fun ReauthBanner(handle: String, onSignInAgain: () -> Unit, modifier: Modifier = Modifier) {
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = AlohaSpacing.m, vertical = AlohaSpacing.xs)
.semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.reauth_banner, handle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSignInAgain) { Text(stringResource(R.string.reauth_sign_in_again)) }
}
}
}

@AlohaPreviews
@Composable
private fun ReauthBannerPreview() {
AlohaTheme { ReauthBanner("alice", onSignInAgain = {}) }
}
55 changes: 55 additions & 0 deletions app/src/main/kotlin/social/aloha/android/AppModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2026 Aloha Social contributors
// SPDX-License-Identifier: MIT

package social.aloha.android

import android.content.Context
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState
import android.os.Build
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import social.aloha.core.data.CleartextAllowed
import social.aloha.core.data.RedirectUriProvider
import social.aloha.core.network.di.UserAgent
import social.aloha.core.network.oauth.OAuthIdentity

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@UserAgent
fun userAgent(): String =
"AlohaSocial/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE}; +https://aloha.social)"

/** Debug builds may sign in to the plain-HTTP dev instance; the network security config agrees. */
@Provides
@CleartextAllowed
fun cleartextAllowed(): Boolean = BuildConfig.DEBUG

/**
* The verified App Link when this install is verified for aloha.social, so no other app can
* receive the code; otherwise, and always below Android 12, the custom scheme, where PKCE makes a
* hijacked code useless.
*/
@Provides
fun redirectUriProvider(@ApplicationContext context: Context): RedirectUriProvider = RedirectUriProvider {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && appLinkVerified(context)) {
OAuthIdentity.APP_LINK_REDIRECT
} else {
OAuthIdentity.SCHEME_REDIRECT
}
}

@androidx.annotation.RequiresApi(Build.VERSION_CODES.S)
private fun appLinkVerified(context: Context): Boolean {
val manager = context.getSystemService(DomainVerificationManager::class.java) ?: return false
val state = manager.getDomainVerificationUserState(context.packageName) ?: return false
return state.hostToStateMap[APP_LINK_HOST] == DomainVerificationUserState.DOMAIN_STATE_VERIFIED
}

private const val APP_LINK_HOST = "aloha.social"
}
63 changes: 63 additions & 0 deletions app/src/main/kotlin/social/aloha/android/AppViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Aloha Social contributors
// SPDX-License-Identifier: MIT

package social.aloha.android

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import social.aloha.core.data.AccountMaintenance
import social.aloha.core.data.AccountRepository
import social.aloha.core.model.SignedInAccount

/** What the root of the app shows. */
sealed interface AppSession {
data object Loading : AppSession

data object SigningIn : AppSession

/** [needsReauth]: the server refused the token; the cache stays and a banner offers a new sign-in. */
data class SignedIn(val handle: String, val needsReauth: Boolean) : AppSession
}

@HiltViewModel
class AppViewModel @Inject constructor(accounts: AccountRepository, maintenance: AccountMaintenance) : ViewModel() {
private val signingInAgain = MutableStateFlow(false)

init {
// once per launch, off the main thread: moved API bases are found and stale capabilities detected again
viewModelScope.launch { maintenance.checkAll() }
// a new sign-in that succeeded ends the request for one
viewModelScope.launch {
accounts.activeAccount.filterNotNull().filter { !it.needsReauth }.collect { signingInAgain.value = false }
}
}

val session: StateFlow<AppSession> =
combine(accounts.accounts, accounts.activeAccount, signingInAgain, ::sessionOf)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), AppSession.Loading)

fun signInAgain() {
signingInAgain.value = true
}

private fun sessionOf(all: List<SignedInAccount>, active: SignedInAccount?, again: Boolean): AppSession = when {
active == null && all.isEmpty() -> AppSession.SigningIn
active == null -> AppSession.Loading
again && active.needsReauth -> AppSession.SigningIn
else -> AppSession.SignedIn(active.handle, active.needsReauth)
}

private companion object {
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
2 changes: 1 addition & 1 deletion app/src/main/kotlin/social/aloha/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
AlohaTheme {
AlohaApp()
AlohaRoot()
}
}
}
Expand Down
Loading
Loading