Guidance for Claude Code (claude.ai/code) when working in this repository.
B-SideLoader is an Android app (Kotlin + Jetpack Compose) that finds, installs and auto-updates
APKs published on GitHub releases and in Telegram channels — an Obtainium-like app store.
It can also install a local APK. Targets Android 8.0+ (minSdk 26), compileSdk/targetSdk 37.
More sources may be added later; the architecture is built for that (see Adding a source).
Gradle wrapper (./gradlew / gradlew.bat), version catalog at gradle/libs.versions.toml — add
or bump dependencies there, referenced as libs.* aliases.
./gradlew assembleDebug # debug APK (per-ABI splits + universal)
./gradlew installDebug # build + install on a connected device
./gradlew :app:testDebugUnitTest # JVM unit tests (fast, no device)
./gradlew :app:connectedDebugAndroidTest # instrumented tests (needs a device)
./gradlew :app:assembleRelease # runs R8; the only way to catch a missing keep rule
./gradlew lintTwo modules: :app and :tdlib (Telegram native wrapper — see below).
Never start an Android emulator, and never run the app on a device yourself. The user always
tests on their own physical phone. installDebug, connectedDebugAndroidTest, adb and the
emulator/simulator tooling are theirs to run — verify locally with assembleDebug,
assembleRelease, testDebugUnitTest and lint, and ask the user to check anything that needs
real hardware.
The Telegram feature needs an API id/hash from https://my.telegram.org/apps. They are obfuscated
at native-compile time. Put them in local.properties (or supply them as env vars in CI):
ID_SECRET,MASK_SECRET,HASH_SECRET— read bytdlib/build.gradle.ktsgetSecret()and passed as CMake/cpp flags totdlib/src/main/cpp/native-lib.cpp, which reconstructs the values at runtime and exposes them throughorg.drinkless.tdlib.Secrets. Never hardcode them in source.
local.properties also holds sdk.dir and is git-ignored. IDE tip: set
idea.max.intellisense.filesize=5000 in idea.properties — TdApi.java is ~4.8 MB.
Clean-ish layering inside a single module, with Hilt DI throughout. Package root
dev.re7gog.b_sideloader. Dependencies point inwards: ui -> domain <- data. The domain
layer has no Android, Room, Retrofit or TDLib imports; check that before adding one.
core/ coroutines/ DispatcherProvider, cancellation-safe runCatching
log/ Logger seam (debug-only chatter compiled behind a lambda)
domain/ model/ TrackedApp, AppSource, UpdateCandidate, InstallProgress, AppSettings...
error/ AppError - the closed hierarchy every failure maps to
repository/ AppsRepository, GithubRepository, TelegramRepository, Settings/Secrets
installer/ InstallerGateway, PackageInspector, ApkStagingArea
background/ BackgroundWorkScheduler, BackgroundRestrictions, BackgroundHealth
device/ DeviceInfo
selection/ NameMatcher, AbiMatcher, Github/TelegramApkSelector (pure, unit-tested)
usecase/ ObserveTrackedApps, ResolveUpdate, InstallApp, RunUpdateSweep, ...
data/ local/ Room database, DAO, entities (+ exported schemas in app/schemas)
remote/ Retrofit GithubApi, DTOs, mappers, OkHttp interceptors
telegram/ TdlibClient (JNI -> coroutines) + TelegramRepositoryImpl + mappers
installer/ session/ and privileged/ backends, event bus, staging, gateway
background/ WorkManager scheduler, worker, monitor service, notifications, OEM quirks
settings/ DataStore-backed settings
encrypt/ Keystore AES-GCM + SecureSecretsRepository
mapper/ entity <-> domain
error/ Throwable -> AppError
device/ AndroidDeviceInfo
di/ Hilt modules (+ src/debug for debug-only bindings)
ui/ BSideLoaderApp.kt navigation-suite shell + Nav3 entryProvider
navigation/ NavKeys, NavigationState, Navigator
common/ component/ text/ error/ permission/ util/ (shared widgets)
feature/<name>/ Screen + ViewModel + UiState per feature
theme/
- The domain owns the models. Room entities, DTOs and
TdApitypes never leavedata; each has a mapper indata/mapperordata/*/mapper. UI-shaped state lives with its feature. - Failures are values of one type. Data-layer code translates its exceptions to
AppError(data/error/ThrowableToAppError.kt,apiCall { }); the UI turns anAppErrorinto text inui/common/error/AppErrorText.kt, which is exhaustive — add a case there when you add one toAppError. - Cancellation is never swallowed. Use
runCatchingCancellable/suspendRunCatchingfromcore/coroutines, or rethrow viaThrowable.rethrowIfCancellation(). A barecatch (e: Exception)around suspending code is a bug. - No
Dispatchers.XoutsideDefaultDispatcherProvider. InjectDispatcherProvider. - No
Contextin a ViewModel. ProduceUiTextand resolve it in the composable. - UI state is immutable.
@Immutable data class+ImmutableList(kotlinx-collections- immutable) so Compose can skip recomposition. - Selection logic is pure. Anything deciding which APK wins goes in
domain/selection, so the details-screen preview and the background sweep run the exact same code.
- Update resolution.
ResolveUpdateUseCaseasks the source repository for raw releases or messages and hands them to the matchingdomain/selectionselector, which applies the app's filters and thenAbiMatcher.UpdateCheck.statuscompares the winner with what is installed. - Install.
InstallAppUseCasestreamsInstallerGateway.install(DownloadRef)and persists the app on success — install and database write are one operation, so nothing has to be correlated afterwards.InstallerGatewayImplpicks a backend per call from the current settings:SessionApkInstaller(standardPackageInstaller, user-confirmed) orPrivilegedApkInstaller(Shizuku/Sui/Dhizuku viahidden-api-bypass+refine). Results are matched by request id throughInstallEventBus; sessions are abandoned on failure and on cancellation. - Background updates.
SyncBackgroundWorkUseCasereconcilesWorkManagerBackgroundSchedulerwith the settings on app start, on boot (BootReceiver) and after every relevant toggle.BackgroundMode.PeriodicusesUpdateCheckWorker;PersistentusesUpdateMonitorService(aspecialUseforeground service —dataSyncis capped at ~6 h/day on Android 14+).RunUpdateSweepUseCaseisolates per-app failures but always propagates cancellation. - Self-update. The app tracks itself like any other app:
SelfAppSeedwrites a row pointing atSelfApp.source(re7gog/B-SideLoader) — fromonCreatefor a new database, from the 1 -> 2 migration for an existing one — with an unknown version, since a GitHub app's stored version is the release name (v1.0.0) and a build only knows itsversionName(1.0.0). The row therefore starts as "not installed from here": it offers an install, the background sweep leaves it alone, and running that install once makes B-SideLoader the installer of record for itself, which is what buys silent updates from then on. Installing it replaces the running process, soInstallAppUseCasenever reaches its own database write: it records aPendingSelfUpdatebefore starting the install, andConfirmSelfUpdateUseCasecompletes the write from the new version's process — called fromMY_PACKAGE_REPLACED(BootReceiver) and again fromApplication.onCreatefor ROMs that drop that broadcast. Whether the install landed is decided byPackageInfo.lastUpdateTime(version code as a second opinion), never by a release name: that first install is a reinstall of the very same build, so the version code does not move. A record whose install never happened is dropped.RunUpdateSweepUseCaseinstalls this app last, because the replace kills whatever is running the sweep. - OEM background limits.
AndroidBackgroundRestrictionsdetects the ROM vendor and resolves only that vendor's autostart activities, verifying each exists before launching it. The "Background reliability" settings screen turns that into a checklist with per-ROM instructions, because no autostart allowlist can be read or requested through an API. - Secrets.
EncryptionManager(AES-256-GCM, hardware Keystore) +SecureSecretsRepositoryhold the TDLib database key and the GitHub token. The token is also exposed synchronously viaAuthTokenSourcesoGithubAuthInterceptorcan attach it without blocking the OkHttp thread.
There is no NavController. The back stack is app state:
ui/navigation/NavKeys.kt—@Serializable ... : NavKeydestinations; arguments are properties.ui/navigation/NavigationState.kt— oneNavBackStackper top-level destination plus which tab is showing; converts toNavEntrys with aSaveableStateHolderand aViewModelStoredecorator per stack (the latter is what scopeshiltViewModelto an entry).ui/navigation/Navigator.kt— the only thing allowed to mutate that state; encodes "exit through home" and the post-install jump back to the apps list.ui/BSideLoaderApp.kt— oneentryProvider { }wiring every destination to its screen.
Screens receive lambdas, never the navigator. A ViewModel that needs a nav argument takes it via
assisted injection: @HiltViewModel(assistedFactory = ...) plus
hiltViewModel<VM, VM.Factory>(creationCallback = { it.create(args) }) — see AppDetailsViewModel.
AGP 9 source-set convention: rules live in app/src/main/keepRules/ (any .keep file) and are
picked up automatically — there is no proguardFiles entry, and keepRules.includeDefault already
pulls in proguard-android-optimize.txt. :tdlib publishes consumer-rules.keep so the app's R8
pass knows the JNI surface must survive; :tdlib itself is not minified (the app minifies
everything once). Note android.r8.strictFullModeForKeepRules is on by default: -keep class A no
longer implies keeping A's default constructor.
Verify keep-rule changes with ./gradlew :app:assembleRelease, then check
app/build/outputs/mapping/release/configuration.txt (which rules reached R8) and mapping.txt
(what survived).
Prebuilt TDLib native libraries stripped from Telegram X live in tdlib/src/main/libs/<abi>/
(libtdjni.so, libsslx.so, libcryptox.so). Java bindings are org.drinkless.tdlib.Client and
TdApi (generated, enormous). TdlibClient owns the native client and adapts it to coroutines;
TelegramRepositoryImpl maps to domain models. The separate CMake native-lib exists only to hold
the obfuscated API secrets.
./gradlew :app:testDebugUnitTest — 123 JVM tests covering selection logic, mappers, error
translation, use cases, ViewModels and the navigation state machine. Fakes (not mocks) live in
app/src/test/java/.../testing/.
./gradlew :app:connectedDebugAndroidTest — Room DAO and migration tests against real SQLite,
plus Compose UI tests. Robolectric is deliberately not used; see docs/testing.md for the toolchain reason and
for how to re-enable it.
- Kotlin official style (
kotlin.code.style=official), non-transitive R classes, Gradle configuration cache on. - New DI bindings go in
data/di/*Module.kt; debug-only bindings inapp/src/debug/.../di/. - ViewModels are constructor-injected
@HiltViewModel; screens have a stateless overload taking a UI state and callbacks, so they can be tested and previewed without a ViewModel. - Room schema is exported to
app/schemas. Changing it means bumpingAppsDatabase.DB_VERSION, adding aMigrationtoAppsDatabase.MIGRATIONS, committing the new JSON, and adding a migration test. There is no destructive fallback. - Kotlin block comments nest. A
/*sequence inside a KDoc (for example writing a glob such assrc/main/followed by a double star) silently swallows the rest of the file. Avoid it.
- Add a variant to
AppSource(andAppSourceKind.storedValue) indomain/model/TrackedApp.kt. - Add a repository interface in
domain/repository/SourceRepositories.ktand its selector indomain/selection/. - Implement the repository in
data/, with DTOs and a mapper; bind it inRepositoryModule. - Add branches in
ResolveUpdateUseCase/ListUpdateCandidatesUseCaseand in the Room mapper. - Add a
SearchSourceentry with its branch inSearchScreen, plus aNavKeyand anAppDetailsArgsvariant.
Nothing else in the UI has to change.