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 sharedipc-contract/module for the AIDL contract.
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/uiautomatorin the environment where this project was put together:gradle assembleDebug— BUILD SUCCESSFUL for both APKs, and after installing, live logs showedlocalUid(MechanicConsole)=10232,remoteUid(AutoServiceHub)=10231— i.e. genuinely different UIDs, not just different PIDs — and correct delivery ofonewayreadings (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 aNoSuchMethodErroronConfiguration.fileCollectionunder Gradle 9.x) — KSP doesn't have this problem and has been the recommended path for Room since 2023.
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).
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_STICKYfor "just keep the general loop going,"START_REDELIVER_INTENTfor "deliver exactly this specific sync request, for this VIN." - Why
onTaskRemoved()is not the same thing asonDestroy(), and why relying onSTART_STICKYalone isn't enough to guarantee "the service really does come back quickly" — hence the explicitAlarmManageras a safety net. - Background-start restrictions since Android 8.0+ (
startForegroundServicevs.startService), the mandatorystartForeground()call within a few seconds,foregroundServiceType(API 34,dataSync), and the separatePOST_NOTIFICATIONSruntime 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,
WorkManageris 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 inipc/— being able to explain when AIDL is actually needed (a cross-process call) versus when it's overkill (communication within a single process).
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
Flowfor reactive queries within the app — a ContentProvider remains the only standard way to expose data to Cursor-based system mechanisms (ContentObserver,CursorLoader, a potentialSyncAdapter), and also the only way to safely share data across processes/apps withreadPermission/writePermissioncontrol, 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) andresolver.notifyChange(uri, null)(explicitly triggers all observers of that Uri) — and why real-world code usually needs both. UriMatcher, and the difference betweenCONTENT_TYPE/CONTENT_ITEM_TYPE(vnd.android.cursor.dir/.item) — when the system even bothers askinggetType().- 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.
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
IDiagnosticsCallbackis declared entirelyoneway, whileIRemoteDiagnosticsServiceis not (see the extended comment right in the.aidlfile): 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/startDiagnosticsScanare 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 madeonewaywith a separate confirmation callback. RemoteCallbackListvs. a plainList<IBinder>: automaticlinkToDeath/onCallbackDied, andbeginBroadcast()/finishBroadcast()as a thread-safe snapshot.IBinder.DeathRecipienton the client side — a way to learn about a remote process crashing that's independent ofServiceConnection.onServiceDisconnected, and the difference betweenonServiceDisconnected(you can wait for the connection to be restored automatically) andonBindingDied(a manual re-bindServiceis required).- Why an AIDL callback always arrives on a Binder pool thread, never on the
client's main thread, and why
DiagnosticsServiceConnectorusesHandler(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 theStubobject. 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 torecentReadings(see the comment inRemoteDiagnosticsService.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=5084was THE SAME for both clients (a single service instance for the whole device), whileremoteUidseen by MechanicConsole (10231) differed from its ownlocalUid(10232), whereas in AutoServiceHub itselflocalUid/remoteUidmatched (10231/10231). - The AIDL contract is pulled out into its own Gradle module,
ipc-contract, instead of copying.aidlfiles between projects: Binder transactions have no versioning — arguments are read from theParcelby position, not by name, so both sides must have code generated from a LITERALLY identical.aidlfor 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 withdangerous).- 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 ownBIND_DIAGNOSTICS, otherwise its ownMainActivitywould have lost access toRemoteDiagnosticsServicethe moment it became protected. - Package visibility (Android 11+, API 30):
<queries><package android:name="com.autoservicehub" /></queries>in MechanicConsole's manifest — without it,PackageManagerqueries about AutoServiceHub (e.g.resolveService, which the connector uses to check ahead of time whether the server is installed, before attemptingbindService) 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" (
resolveServicereturnsnull, checked BEFOREbindService) and "missing permission" (bindServicethrowsSecurityExceptionif the apps are signed with different keys) — both paths are explicitly handled inmechanicconsole/.../DiagnosticsServiceConnector.ktand 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 explicitboundRequestedflag rather than a null check (the service can legitimately becomenull— the remote process dying — while the binding itself is still registered andunbind()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.
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
:remotehas the same UID as the main process but a different PID (this can be demonstrated live by comparinggetRemoteProcessId()/getHostUid()from the AIDL response againstProcess.myPid()/Process.myUid()inMainActivity). 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_serverat the Binder-call boundary (example:RECEIVE_BOOT_COMPLETEDonBootCompletedReceiver). - Crash isolation: a crash in
:remote's code doesn't bring down the main process — the caller gets aRemoteException/DeadObjectExceptioninstead of a crash. A direct parallel to how the system itself isolates things like the WebView renderer or system services. android:exportedas 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.
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 onUI_HIDDEN, aggressive cleanup onRUNNING_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 ofonTrimMemory(TRIM_MEMORY_COMPLETE)— and why new code should rely ononTrimMemoryinstead.- Why
ComponentCallbacks2is registered at theApplicationlevel (App.onCreate) rather than on a specific Activity — an Activity gets recreated many times, the process doesn't. RunningAppProcessInfo.importanceand its connection tooom_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:remotedying as a routine event (seeDeathRecipientinDiagnosticsServiceConnector), not as a bug.- Why wrapping a callback in a
WeakReferenceis 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.ktapplies the same idea directly viajava.lang.ref.WeakReference(without reusingWeakCallbackHolder— 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.
- 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 viaadb/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 — otherwisebindService()from MechanicConsole would get aSecurityException(this path is also explicitly handled, see section 3).