Skip to content

About

Multi-module Android/Kotlin demo: Services, ContentProvider, cross-app Binder/AIDL IPC, process internals, memory management

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

AutoServiceHub

A demonstration multi-module Android project (Kotlin), written as a witness project for a technical interview covering five areas of Android development. There's enough code here that each topic isn't just "I've read about this" but "here's a working implementation, here's the file, here's why it's done this particular way."

Two separate, independently installable apps:

  • AutoServiceHub (app/, applicationId "com.autoservicehub") — a simplified "hub" for an auto-service business: background sync of vehicle data with real-time diagnostics shown from a separate process.
  • MechanicConsole (mechanicconsole/, applicationId "com.mechanicconsole") — an independent client app that connects to AutoServiceHub's diagnostics service over AIDL in a genuinely cross-app way (a different APK, a different Linux UID), not just from another process of the SAME app. Both apps depend on the shared ipc-contract/ module for the AIDL contract.

How to build and run

Requires Android Studio (Koala+) / JDK 17 / Android SDK 35 (compileSdk/ targetSdk; minSdk 26). Open the repository root as a project — Gradle will pull in AGP 8.5.2 and Kotlin 1.9.24 automatically.

./gradlew assembleDebug          # builds both APKs + the contract library
adb install -r -g app/build/outputs/apk/debug/app-debug.apk
adb install -r -g mechanicconsole/build/outputs/apk/debug/mechanicconsole-debug.apk

To see the real cross-app AIDL exchange: open AutoServiceHub → "Bind to the :remote service" (or just open MechanicConsole directly — the service comes up on demand from either client), then open MechanicConsole → "Connect to AutoServiceHub" → "Start sensor scan". Both apps will show the same remotePid in their log (the same physical :remote process of AutoServiceHub serves both clients), but a DIFFERENT localUid/remoteUid in MechanicConsole and a matching localUid/remoteUid in AutoServiceHub itself.

Both apps were actually built, installed on an emulator (Pixel 10 Pro XL, API 36), and verified via adb/uiautomator in the environment where this project was put together: gradle assembleDebug — BUILD SUCCESSFUL for both APKs, and after installing, live logs showed localUid(MechanicConsole)=10232, remoteUid(AutoServiceHub)=10231 — i.e. genuinely different UIDs, not just different PIDs — and correct delivery of oneway readings (battery_voltage, engine_temp, etc) from AutoServiceHub to MechanicConsole in real time. The specific UID/PID numbers on your device will be different (the system assigns them at install time); the fact itself — "UIDs differ between two apps but match within one" — is always reproducible. Room annotations are processed via KSP, not kapt: the old kapt plugin is incompatible with newer Gradle versions (in particular, it hits a NoSuchMethodError on Configuration.fileCollection under Gradle 9.x) — KSP doesn't have this problem and has been the recommended path for Room since 2023.

Project structure

settings.gradle.kts        — include(":app", ":ipc-contract", ":mechanicconsole")

ipc-contract/               — shared Android library module with the AIDL contract
└── src/main/
    ├── aidl/.../ipc/       — IRemoteDiagnosticsService.aidl, IDiagnosticsCallback.aidl,
    │                         DiagnosticReading.aidl
    └── java/.../ipc/DiagnosticReading.kt   — the Parcelable sensor-reading model

app/src/main/               — AutoServiceHub (the service implementation, the "server")
├── AndroidManifest.xml            — registers all components + permissions
├── java/com/autoservicehub/
│   ├── App.kt                     — Application: notification channel, ComponentCallbacks2
│   ├── services/                  — 1. ANDROID SERVICES
│   │   ├── SyncForegroundService.kt
│   │   ├── SyncServiceConnection.kt
│   │   ├── BootCompletedReceiver.kt
│   │   └── ServiceRestartAlarmReceiver.kt
│   ├── provider/                  — 2. CONTENTPROVIDER
│   │   ├── VehicleContract.kt
│   │   ├── VehicleEntity.kt / VehicleDao.kt / VehicleDatabase.kt
│   │   └── VehicleContentProvider.kt
│   ├── ipc/                       — 3. BINDER / AIDL (implementation + its own client)
│   │   ├── RemoteDiagnosticsService.kt   (the :remote process, exported+permission)
│   │   └── DiagnosticsServiceConnector.kt (the same app's own client)
│   ├── internals/                 — 4. ANDROID INTERNALS
│   │   └── ProcessInternalsInspector.kt
│   ├── memory/                    — 5. MEMORY MANAGEMENT
│   │   ├── AppComponentCallbacks.kt
│   │   ├── MemoryPressureLogger.kt
│   │   └── WeakCallbackHolder.kt
│   └── ui/MainActivity.kt         — the assembly point for all five areas

mechanicconsole/src/main/   — MechanicConsole (a separate client app)
├── AndroidManifest.xml            — <uses-permission> on another app's signature permission, <queries>
└── java/com/mechanicconsole/
    ├── ipc/DiagnosticsServiceConnector.kt  — the cross-app (explicit ComponentName) client
    └── ui/MainActivity.kt

All five topics from the original brief physically intersect in a single scenario inside AutoServiceHub: the user taps a button in MainActivity → SyncForegroundService's background process writes to Room → VehicleContentProvider broadcasts notifyChange() → the ContentObserver in MainActivity reacts to the change → in parallel, MainActivity binds over AIDL to RemoteDiagnosticsService, which lives in the :remote process, and receives asynchronous readings via a oneway callback.

On top of that, there's a sixth, separate case — genuine cross-app IPC: mechanicconsole/ — a standalone app that isn't part of the original five topics, but that directly extends the Binder/AIDL topic to its logical conclusion (see section 3 below).


1. Android Services — level 4

What's implemented: services/SyncForegroundService.kt — a foreground service with explicit handling of START_STICKY vs. START_REDELIVER_INTENT (the choice depends on intent.action — see the comments in onStartCommand), its own LocalBinder for local binding from MainActivity, restart-after-onTaskRemoved via AlarmManager (ServiceRestartAlarmReceiver), and restart after a device reboot (BootCompletedReceiver). The service coordinates another component — VehicleContentProvider — by calling contentResolver.notifyChange() after every write.

Talking points for an interview:

  • The difference between START_STICKY / START_REDELIVER_INTENT / START_NOT_STICKY, and why the choice is a tradeoff between "don't lose a specific request's parameters" and "don't spawn duplicate restarts with an empty intent." In this project both modes are used deliberately in the same class — START_STICKY for "just keep the general loop going," START_REDELIVER_INTENT for "deliver exactly this specific sync request, for this VIN."
  • Why onTaskRemoved() is not the same thing as onDestroy(), and why relying on START_STICKY alone isn't enough to guarantee "the service really does come back quickly" — hence the explicit AlarmManager as a safety net.
  • Background-start restrictions since Android 8.0+ (startForegroundService vs. startService), the mandatory startForeground() call within a few seconds, foregroundServiceType (API 34, dataSync), and the separate POST_NOTIFICATIONS runtime permission (API 33+), because of which the service's notification can technically be invisible to the user while the service is still required to keep running.
  • Why, for "just periodic sync" in a real production app, WorkManager is often the right call instead of a classic foreground service — and in which case (a persistent visible progress indicator is needed, the work must be resilient to Doze right here, right now, rather than "whenever the system finds it convenient") a foreground service is still justified.
  • The contrast between LocalBinder (this class) and the AIDL binder in ipc/ — being able to explain when AIDL is actually needed (a cross-process call) versus when it's overkill (communication within a single process).

2. ContentProvider — level 3

What's implemented: provider/VehicleContract.kt (a contract class with AUTHORITY, CONTENT_URI, column names, MIME types) and provider/VehicleContentProvider.kt — a ContentProvider on top of Room, where query()/insert()/update()/delete() work directly against SupportSQLiteDatabase (not through the Room DAO), and support Cursor.setNotificationUri() plus explicit ContentResolver.notifyChange().

Talking points for an interview:

  • Why even bother with a ContentProvider on top of Room, when Room already gives you Flow for reactive queries within the app — a ContentProvider remains the only standard way to expose data to Cursor-based system mechanisms (ContentObserver, CursorLoader, a potential SyncAdapter), and also the only way to safely share data across processes/apps with readPermission/writePermission control, should that ever be needed.
  • The contract-class pattern that eliminates "magic strings" for URIs/ columns — a comparison with the platform's ContactsContract/MediaStore.
  • Two independent but complementary notification mechanisms: Cursor.setNotificationUri(resolver, uri) (ties a specific cursor to a Uri) and resolver.notifyChange(uri, null) (explicitly triggers all observers of that Uri) — and why real-world code usually needs both.
  • UriMatcher, and the difference between CONTENT_TYPE/CONTENT_ITEM_TYPE (vnd.android.cursor.dir / .item) — when the system even bothers asking getType().
  • Why exported="false" is a deliberate choice for the app's private data, and what would need to change (permission protection, exported="true") if external access were ever required.

3. Binder / AIDL — level 3 (and a bit beyond)

What's implemented: ipc-contract/.../IRemoteDiagnosticsService.aidl (the control interface) and IDiagnosticsCallback.aidl (the oneway callback) — a library module shared by both apps; the implementation is app/.../ipc/RemoteDiagnosticsService.kt (a Service in the :remote process, using RemoteCallbackList to safely handle multiple clients and client death). The service has TWO independent clients: app/.../ipc/DiagnosticsServiceConnector.kt (the same app, same UID) and mechanicconsole/.../ipc/DiagnosticsServiceConnector.kt (a separate app, a different UID, explicit binding by ComponentName(package, class), protected by the signature permission com.autoservicehub.permission.BIND_DIAGNOSTICS from app/src/main/AndroidManifest.xml).

Talking points for an interview:

  • Why IDiagnosticsCallback is declared entirely oneway, while IRemoteDiagnosticsService is not (see the extended comment right in the .aidl file): the "service → client" direction is asynchronous and must not block the service's Binder pool; the "client → service" direction, for state-management commands, is deliberately synchronous to guarantee the order "sent → processed → safe to unbind now." An honest point for discussion: registerCallback/startDiagnosticsScan are called here from the client's main thread (onServiceConnected, a button handler) — that's acceptable as long as the calls are fast, but in production, if the other side's processing could be slow, such calls should either be moved to a background thread on the client, or also made oneway with a separate confirmation callback.
  • RemoteCallbackList vs. a plain List<IBinder>: automatic linkToDeath/onCallbackDied, and beginBroadcast()/finishBroadcast() as a thread-safe snapshot.
  • IBinder.DeathRecipient on the client side — a way to learn about a remote process crashing that's independent of ServiceConnection.onServiceDisconnected, and the difference between onServiceDisconnected (you can wait for the connection to be restored automatically) and onBindingDied (a manual re-bindService is required).
  • Why an AIDL callback always arrives on a Binder pool thread, never on the client's main thread, and why DiagnosticsServiceConnector uses Handler(Looper.getMainLooper()).
  • Binder.getCallingUid()/getCallingPid() as an identity check of the calling side at the IPC boundary — a foundation of the security model, applicable regardless of whether the service is exported or not.
  • The Binder transaction size limit (~1 MB per process) as a reason not to design methods like getLastReadings() for genuinely large datasets without pagination/streaming.
  • A live Kotlin+AIDL gotcha caught while building this project: the AIDL method getLastReadings() generates a Java getter, and Kotlin automatically turns such getters into a synthetic read-only property (lastReadings: List<...>) right on the Stub object. A service field with the same name (lastReadings: MutableList<...>) inside THAT SAME object's methods ended up shadowed by that synthetic property, and .add()/.removeAt() stopped resolving with a non-obvious compiler error — the fix: the field was renamed to recentReadings (see the comment in RemoteDiagnosticsService.kt). A good example of how implementing a Stub for an AIDL interface isn't "just another Kotlin class" — it has its own cross-language (Java codegen / Kotlin consumption) quirks.

Separately — on the genuine cross-app scenario with MechanicConsole:

  • The difference between "inter-process" and "inter-app" IPC — in this project both varieties literally coexist around the SAME service: AutoServiceHub↔:remote — same UID, different PID (crash/memory isolation within a single data owner); MechanicConsole↔:remote — different UID, different PID (a genuine inter-app security boundary). A live example (taken from an actual emulator run): remotePid=5084 was THE SAME for both clients (a single service instance for the whole device), while remoteUid seen by MechanicConsole (10231) differed from its own localUid (10232), whereas in AutoServiceHub itself localUid/ remoteUid matched (10231/10231).
  • The AIDL contract is pulled out into its own Gradle module, ipc-contract, instead of copying .aidl files between projects: Binder transactions have no versioning — arguments are read from the Parcel by position, not by name, so both sides must have code generated from a LITERALLY identical .aidl for compatibility. The production alternative to this module is publishing the contract as its own versioned AAR to a private vendor Maven repository, if the apps physically can't live in the same Gradle project.
  • android:exported="true" plus a <permission protectionLevel="signature"> of its own: the system grants the permission automatically only to apps signed with the same certificate — the standard way to open up a Binder API "to your own vendor's apps, closed to everyone else," without showing the user a consent dialog (as would happen with dangerous).
  • A non-obvious but essential nuance of the permission model, caught during implementation: declaring a <permission> in the manifest does NOT grant that permission automatically to the declaring app itself — AutoServiceHub had to ADDITIONALLY declare a <uses-permission> for its own BIND_DIAGNOSTICS, otherwise its own MainActivity would have lost access to RemoteDiagnosticsService the moment it became protected.
  • Package visibility (Android 11+, API 30): <queries><package android:name="com.autoservicehub" /></queries> in MechanicConsole's manifest — without it, PackageManager queries about AutoServiceHub (e.g. resolveService, which the connector uses to check ahead of time whether the server is installed, before attempting bindService) would, on newer Android versions, simply not see this app, as if it didn't exist.
  • Realistic handling of failure modes that don't exist in the same-app scenario: "the server isn't installed" (resolveService returns null, checked BEFORE bindService) and "missing permission" (bindService throws SecurityException if the apps are signed with different keys) — both paths are explicitly handled in mechanicconsole/.../DiagnosticsServiceConnector.kt and return the user a clear reason instead of crashing the app.
  • Context.bindService()/unbindService() — the contract "unbind is only allowed to be called if the preceding bindService returned true" is honored via an explicit boundRequested flag rather than a null check (the service can legitimately become null — the remote process dying — while the binding itself is still registered and unbind() is still mandatory).
  • packageManager.getPackagesForUid(callingUid) on the service side — defense-in-depth on top of the manifest permission: the system itself would already have refused the call without the right permission, but explicit logging/a potential allowlist by package name is a standard second layer, for the case where different partner apps eventually need different levels of access to the same protected component.

4. Android Internals — level 3

What's implemented: internals/ProcessInternalsInspector.kt — not a standalone feature, but a utility that logs the PID/UID/importance of the current process, called both in the main process (App.onCreate, MainActivity) and, implicitly, applicable to the :remote process. The main value here is in the comments explaining the process model.

Talking points for an interview:

  • UID as the true sandbox boundary (Linux DAC), PID as the identifier of a specific process — and why :remote has the same UID as the main process but a different PID (this can be demonstrated live by comparing getRemoteProcessId()/getHostUid() from the AIDL response against Process.myPid()/Process.myUid() in MainActivity). In this project the difference between "different process, same UID" (AutoServiceHub↔:remote) and "different process, different UID" (MechanicConsole↔:remote) isn't hypothetical — it was verified live on an emulator, see section 3 above.
  • Zygote: why forking an already-warmed-up process is cheaper than a full exec(), and copy-on-write memory.
  • Permissions as a SECOND layer of control, separate from UID sandboxing, enforced by system_server at the Binder-call boundary (example: RECEIVE_BOOT_COMPLETED on BootCompletedReceiver).
  • Crash isolation: a crash in :remote's code doesn't bring down the main process — the caller gets a RemoteException/DeadObjectException instead of a crash. A direct parallel to how the system itself isolates things like the WebView renderer or system services.
  • android:exported as an explicit entry point into the permission model at the manifest level — what happens if you forget to set/protect it on a component that's actually meant to be private.

5. Memory Management — level 3

What's implemented: memory/AppComponentCallbacks.kt (onTrimMemory with every level broken down in comments, onLowMemory), memory/MemoryPressureLogger.kt (ActivityManager.MemoryInfo, RunningAppProcessInfo.importance), memory/WeakCallbackHolder.kt (a reusable WeakReference wrapper, used in ipc/DiagnosticsServiceConnector.kt for the callback listener).

Talking points for an interview:

  • The full TRIM_MEMORY_* scale and what's appropriate to do at each level (freeing UI caches on UI_HIDDEN, aggressive cleanup on RUNNING_CRITICAL/COMPLETE) — not just listing the constants, but explaining the progression "from a soft signal to a process the system doesn't plan to kill, all the way to a signal for a process that's literally first in line to die."
  • onLowMemory() as the deprecated, cruder predecessor of onTrimMemory(TRIM_MEMORY_COMPLETE) — and why new code should rely on onTrimMemory instead.
  • Why ComponentCallbacks2 is registered at the Application level (App.onCreate) rather than on a specific Activity — an Activity gets recreated many times, the process doesn't.
  • RunningAppProcessInfo.importance and its connection to oom_score_adj/ LMKD: why cached processes (in this project, that's often :remote, when nothing is bound to it) get killed first and without warning, and why client code must treat :remote dying as a routine event (see DeathRecipient in DiagnosticsServiceConnector), not as a bug.
  • Why wrapping a callback in a WeakReference is specifically a defense against a CROSS-PROCESS leak: a strong reference held by an object on the other side of the Binder boundary isn't visible to ordinary tools like LeakCanary, which operate within a single process/heap. mechanicconsole/.../DiagnosticsServiceConnector.kt applies the same idea directly via java.lang.ref.WeakReference (without reusing WeakCallbackHolder — that one deliberately stayed an internal AutoServiceHub utility rather than a shared dependency, so the separate app doesn't have to pull in someone else's app module for one small class) — the same cross-process leak risk applies here just as much, only the boundary is now cross-app too.

Deliberate limitations of this demo

  • Sensor and sync data is simulated (Random) — the goal of this project isn't real auto-service business logic, it's a pure demonstration of architectural mechanisms.
  • There are no automated tests — they weren't explicitly requested, to avoid expanding the project's scope beyond the five assigned topics.
  • Both apps have been built (gradle assembleDebug, both APKs BUILD SUCCESSFUL) and actually verified on an emulator (installation, a live cross-app AIDL exchange via adb/uiautomator) in the environment where this project was put together — not just read over with the eyes.
  • The signature permission between AutoServiceHub and MechanicConsole works in this repository because both apps, when built locally, are signed with the same debug key (~/.android/debug.keystore, shared by default across all debug builds on one machine). For real production, with two independently published apps, both APKs would need to be signed with the same vendor release key — otherwise bindService() from MechanicConsole would get a SecurityException (this path is also explicitly handled, see section 3).

About

Multi-module Android/Kotlin demo: Services, ContentProvider, cross-app Binder/AIDL IPC, process internals, memory management

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages