Services & DI
Picodroid mirrors the Android Service shape closely enough that an Android developer can pick it up without re-learning the pattern, but pares the lifecycle down to what fits on a Pico: no Binder IPC, no remote services, no system-process dispatch.
picodroid.app.Service
A long-running background component with a lifecycle independent of any Activity. It extends Context, as on Android, so getSystemService, getSharedPreferences, startService and bindService are available on this. Subclass it in your app:
package myapp;
import picodroid.app.Service;import picodroid.os.IBinder;import picodroid.content.Intent;import picodroid.util.Log;
public class CounterService extends Service { private int count; private final LocalBinder binder = new LocalBinder();
@Override public void onCreate() { binder.service = this; // wire the handle up front Log.i("CounterService", "onCreate"); }
@Override public int onStartCommand(Intent intent, int flags, int startId) { count++; Log.i("CounterService", "tick=" + count); return Service.START_STICKY; // return value is ignored on picodroid (see below) }
@Override public IBinder onBind(Intent intent) { return binder; }
@Override public void onDestroy() { Log.i("CounterService", "onDestroy"); }
// IBinder is an interface; LocalBinder just carries a direct reference to the // service (single-process — there is no IPC stub generation). public static class LocalBinder implements IBinder { public CounterService service; }}Lifecycle
| Callback | When it fires |
|---|---|
onCreate() | Once, the first time the service is started or bound. |
onStartCommand(Intent, int flags, int startId) | Each call to Context.startService() (including repeats). flags is always 0 — START_FLAG_REDELIVERY / START_FLAG_RETRY describe redelivery after a process kill, which never happens on an MCU. The return value is ignored on picodroid — see the note below. |
onBind(Intent) | First call to Context.bindService() for this service. Return an IBinder (typically a custom LocalBinder). Cached and reused across subsequent binds. |
onUnbind(Intent) | Last bound client unbinds. Default returns false; return true to receive onRebind when a new client binds later. |
onRebind(Intent) | A client binds again after onUnbind returned true (and the service was not destroyed in between). onBind is not called again — the cached IBinder is reused, matching Android’s contract. |
onDestroy() | Service is being torn down (last unbind + no startService keepalive, or explicit stopService). |
On picodroid the OS never kills a running service, so onStartCommand’s return value has no runtime effect — START_STICKY and START_NOT_STICKY exist only for source-level Android compatibility. Return START_STICKY by convention.
picodroid.os.IBinder
Marker interface for the object handed back from onBind. Implement it with your own LocalBinder that carries a reference to the service (no IPC stub generation in v1 — LocalBinder is just a Java reference handed across bindService):
public static class LocalBinder implements IBinder { public CounterService service;}Picodroid is single-process, so there is no AIDL / Messenger / true Binder IPC. Clients cast the IBinder they receive back to your LocalBinder type and read the field.
picodroid.app.Notification and startForeground
A foreground service shows a persistent banner while it runs. There is no idle or low-memory kill policy on an MCU, so “foreground” here is about the banner, not about survival. To opt in, build a Notification and call startForeground from onStartCommand:
import picodroid.app.Notification;
@Overridepublic int onStartCommand(Intent intent, int flags, int startId) { Notification n = new Notification.Builder() .setContentTitle("Logging sensors") .setContentText("ring buffer 0/256") .build(); startForeground(NOTIFICATION_ID, n); return Service.START_STICKY;}stopForeground(true) removes the notification; onDestroy cancels it automatically.
picodroid.app.NotificationManager
For notifications outside the foreground-service flow, post or cancel by ID through the
NotificationManager singleton. Picodroid renders every notification as a single persistent top-of-screen banner.
import picodroid.app.Notification;import picodroid.app.NotificationManager;
Notification n = new Notification.Builder() .setContentTitle("Upload complete") .build();
NotificationManager nm = NotificationManager.getInstance();nm.notify(1, n); // post under id 1nm.cancel(1); // dismiss itpicodroid.content.Context — start / bind / stop
The Context (your Application, an Activity, or another Service) drives the service lifecycle:
import picodroid.content.Intent;import picodroid.content.ServiceConnection;import picodroid.os.IBinder;
Intent i = new Intent(CounterService.class);
// Fire-and-forget: invokes onStartCommandstartService(i);
// Bind: invokes onBind, then onServiceConnectedServiceConnection conn = new ServiceConnection() { public void onServiceConnected(IBinder binder) { CounterService s = ((CounterService.LocalBinder) binder).service; // call s.someMethod() ... } public void onServiceDisconnected() { // last unbind, owning Activity destroyed, or app exit — drop the reference }};bindService(i, conn); // 2-arg; binding implicitly creates the service if needed
unbindService(conn);stopService(i);bindService takes just (Intent, ServiceConnection) — there is no flags parameter and no
Context.BIND_AUTO_CREATE constant; binding always creates the service if it isn’t running.
Dependency injection: @Inject / @Singleton
Picodroid ships a compile-time DI framework in the Dagger/Hilt shape. Annotate constructors, fields and methods with JSR-330’s javax.inject.Inject, scope app-wide objects with javax.inject.Singleton, and the build generates the wiring. Nothing is resolved at runtime — pico-jvm has no reflection and drops annotations from class files — so the only thing that reaches the device is ordinary generated Java.
import javax.inject.Inject;import javax.inject.Singleton;
@Singletonpublic class SensorRepository { @Inject public SensorRepository(Formatter formatter) { /* ... */ } // constructor injection}
public class Formatter { @Inject public Formatter() {}}
public class HomeActivity extends Activity { @Inject SensorRepository repo; // field injection — set before onCreate() @Inject Formatter formatter;
@Override public void onCreate() { // repo and formatter are already populated }}What the build does:
-
Every class with an
@Injectconstructor gets a generatedFoo_Factorywithpublic static Foo get(). Unscoped classes yield a fresh instance per injection; a@Singletonclass yields one instance per process, created lazily on first use and held in a static field (a GC root shared by every thread). -
Every class with
@Injectfields or methods gets a generatedFoo_MembersInjector. Superclass members are injected first, then fields, then methods, each in declaration order. -
Framework-owned components —
Application,Activity,Service— are injected automatically right after construction and beforeonCreate(), like Hilt’s@AndroidEntryPoint. They keep their no-arg constructor; use field or method injection there. -
Anything else is pulled from the graph with
Foo_Factory.get(), the equivalent of a Dagger component accessor (seeMessage_Factory.get()ininjectdemo). -
Types that cannot carry an
@Injectconstructor — SDK classes, interfaces, abstract types — are bound by@Providesmethods on a@Moduleclass (picodroid.di.Module/picodroid.di.Provides, thedagger.Module/dagger.Providescounterparts). Every@Modulein the app is installed automatically; there is no@Componentto declare. Methods may bestatic(preferred) or instance methods on a module with a no-arg constructor (the module is then created once, lazily); parameters are injected like constructor parameters;@Singletonon the method scopes the value. Each method becomes a generatedMod_ProvideFooFactory.@Modulepublic final class AppModule {@Provides @Singletonstatic SharedPreferences providePrefs() { return SharedPreferences.open("app"); }@Providesstatic Greeter provideGreeter(Clock clock) { return new FriendlyGreeter(clock); }} -
javax.inject.Provider<T>andpicodroid.di.Lazy<T>(thedagger.Lazycounterpart) can be injected anywhere aTcan. AProviderhands out a fresh instance perget()for unscoped types (the one instance for a@Singleton) and constructs nothing until called; aLazycalls the factory once and memoizes. Both break dependency cycles, since neither constructs anything at injection time. They are generated on demand asT_Provider/T_Lazy.
Rules the compiler enforces — each violation is a compile error that says why:
- One
@Injectconstructor per class, never on an abstract class or on anApplication/Activity/Servicesubclass (the framework constructs those with the no-arg constructor). @Injectfields must be non-private, non-final and non-static; the injector lives in the same package, so package-private is the idiom.@Injectmethods must be non-private, non-static, non-abstract and non-generic.- Every dependency must have exactly one binding in the app — an
@Injectconstructor on a concrete, non-generic class, or one@Providesmethod — optionally wrapped in oneProvider<T>/Lazy<T>. A type bound twice is an error. Parameterized types (other than the two wrappers), qualifiers and@Bindsare not supported yet. - No dependency cycles through direct constructor or member edges; break one with
Provider<T>orLazy<T>. - An
@Injectfield’s name must not be reused anywhere in its superclass or subclass chain: pico-jvm resolves instance fields by name only. @Singletonis the only scope; it goes on a class with an@Injectconstructor or on a@Providesmethod.@Providesmethods must live in a@Module, be non-private and non-abstract, return a class or interface type, and have unique names within the module; a module cannot itself have an@Injectconstructor or members.
Divergences worth knowing: the annotations have SOURCE retention (JSR-330 says RUNTIME), so using them adds nothing to the PAPK; the generated classes ship inside the PAPK and cost about 20 B of RAM each at boot plus their metadata when first touched, and no firmware flash at all. Design, generated-code contract and roadmap: docs/designs/inject-annotations-2026-08.md. End-to-end example: examples/injectdemo/; picoenvmon uses it throughout.
Kotlin apps get the same processor through kapt (picodroid-papk-kotlin applies it; nothing to configure). The shapes that work: @Inject lateinit var for fields (a lateinit property has a public backing field; a plain var/val has a private one and is rejected), @Inject constructor(...), @Singleton on the class, a @Module object whose @Provides methods are @JvmStatic (the static path), and a @Module class for instance @Provides methods. Avoid @Provides in a companion object (kotlinc emits the method twice, so one copy is always a stray outside the module), @Module object methods without @JvmStatic (the object’s constructor is private, which the instance path rejects), inner class, and kotlin.Lazy — import picodroid.di.Lazy. Kotlin twin of the example: examples/injectdemo_kt/.
Manual DI: ApplicationComponent / ActivitySingletonComponent
The hand-written shape predates @Inject and remains supported for apps that want an explicit graph with no generated code:
import picodroid.di.ApplicationComponent;import picodroid.di.ActivitySingletonComponent;
public final class EnvAppComponent extends ApplicationComponent { private final SensorRepository repo = new SensorRepository(/* ... */); public SensorRepository sensorRepository() { return repo; }}
public final class HomeActivityComponent extends ActivitySingletonComponent { private final HomeViewModel vm; public HomeActivityComponent() { // no-arg constructor EnvAppComponent app = (EnvAppComponent) app(); // app() resolves the ApplicationComponent this.vm = new HomeViewModel(app.sensorRepository()); } public HomeViewModel viewModel() { return vm; }}ApplicationComponent is process-singleton — its constructor stores itself, and ApplicationComponent.current() (or the protected app() accessor inside an ActivitySingletonComponent) returns it. ActivitySingletonComponent has a no-arg constructor that grabs current() for you; it is per-Activity-instance and recreated on every onCreate. Construct your ApplicationComponent once in Application.onCreate() and your ActivitySingletonComponent in each Activity.onCreate().
This pattern keeps the dependency graph explicit, statically typed, and visible in the source — no reflection, no codegen, no startup cost.
The two styles coexist: an ApplicationComponent subclass can itself be a @Singleton with an @Inject constructor, which makes it injectable while ApplicationComponent.current() keeps working for legacy call sites (LegacyComponent in injectdemo).
See examples/servicedemo/ for the full Service v1 lifecycle in one non-UI run, and examples/picoenvmon/ for @Inject / @Singleton in production-shape code.