Hosting the services¶
Every client call arrives somewhere. That somewhere is a host app: it holds the accounts, the
tokens and the keys, exports five bound services, and decides what each calling app may do.
Solid Share is the host, and the host library is what it is built on.
You probably do not need this page
Installing Solid Share is the supported answer. A second host splits the user's accounts across two apps, and a device with two hosts asks people to understand a distinction they should never have to. Read on if you are forking the ecosystem, shipping into an environment Solid Share cannot reach, or working on Solid Share itself.
dependencies {
implementation("com.erfangholami.androidsolidservices:host:0.8.0")
implementation("com.erfangholami.androidsolidservices:api:0.8.0")
}
host depends on api, because it serves calls by making them: your process is the one that talks
to the pod.
The five services¶
Each service is a few lines. Extend HostService, build the binder from what you inject, and let
the base class own the dispatch scope and caller attribution.
@AndroidEntryPoint
class MyResourceService : HostService() {
@Inject lateinit var resourceManager: SolidResourceManager
@Inject lateinit var session: HostSession
@Inject lateinit var guard: AccessGuard
override fun createBinder(): IBinder = ResourceBinder(
resourceManager = resourceManager,
session = session,
guard = guard,
scope = serviceScope, // (1)!
spoolDirectory = cacheDir, // (2)!
)
}
HostServicecancels this inonDestroy, so no pod call outlives its service.- Where a streamed upload is spooled before it is sent, because the request may legitimately be re-sent.
Declare each one against its action from SolidHostContract. The SDK binds by action inside your
package, never by class name, so these classes stay yours to rename.
<service android:name=".ipc.MyResourceService" android:exported="true">
<intent-filter>
<action android:name="com.erfangholami.androidsolidservices.action.RESOURCES" />
</intent-filter>
</service>
| Binder | Action | Wraps |
|---|---|---|
AuthenticatorBinder |
ACTION_AUTHENTICATOR |
session state and the caller's own grant |
ResourceBinder |
ACTION_RESOURCES |
SolidResourceManager |
DataModulesBinder |
ACTION_DATA_MODULES |
the contacts and tickets modules |
SharingBinder |
ACTION_SHARING |
SharingManager |
NotificationsBinder |
ACTION_NOTIFICATIONS |
NotificationsManager |
Do not add android:permission
The grant is the guard. A custom permission adds nothing, because any app can declare one, and it would only stop apps that had already been refused.
The guard¶
AccessGuard is the three gates every verb passes: the caller resolves to a package, the WebID has
a live session, and the grant covers the verb. Build one and share it across the binders.
val grants: AppGrantStore = DataStoreAppGrantStore(preferencesDataStore)
val moduleRoots: ModuleRootResolver = CachingModuleRootResolver(
DataModuleRootResolver(contactsDataModule, ticketsDataModule),
)
val guard = AccessGuard(
session = AuthenticatorHostSession(authenticator),
policy = ScopedAccessPolicy(grants, moduleRoots),
)
ScopedAccessPolicy implements the rules the App access page documents, and
VerbAccess is that verb table in code. ModuleRootResolver answers where a data module lives on
a given pod, so a grant on Contacts can cover a raw resource call that names one of its containers;
only the host can know that, which is why it is an interface you supply.
The caller's package comes from Binder.getCallingUid(), which is trustworthy — but it is readable
only while the binder transaction is on the stack. AccessGuard.gate(...) captures it for you at
the right moment; if you write your own dispatch, read it before you launch.
The consent screen¶
AuthorizeProtocol is the host's half of the sign-in contract. Your activity reads the request,
lets the user decide, and finishes with one of three results.
class MyAuthorizeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val caller = CallerIdentity.of(this) ?: return finishWith( // (1)!
AuthorizeProtocol.errorResult(ExceptionsErrorCode.UNKNOWN, "Start me for a result."),
)
val request = AuthorizeProtocol.requestFrom(intent) // (2)!
// …show the caller, the accounts, the level and the targets…
val entries = AuthorizeProtocol.entriesFor(request, storageRootsOf(webId)) // (3)!
val grant = AppGrant(caller.packageName, webId, caller.label, entries, Instant.now().toString())
grants.put(grant)
finishWith(AuthorizeProtocol.grantedResult(webId, grant))
}
}
nullwhen the activity was not started for a result. Refuse then: Android names the calling package only in that case, and a grant recorded against a caller you cannot name is a grant for whoever asks next.AccessRequest.DEFAULTwhen the app sent none — the whole pod at Edit.- Requested paths are storage-relative, because at request time the app does not know the WebID. This resolves each against every storage root of the chosen account.
Declare the activity with ACTION_AUTHORIZE and the DEFAULT category. The SDK sends an Intent
carrying an action and a package but no component, and activity resolution only matches filters
that declare DEFAULT.
<activity android:name=".MyAuthorizeActivity" android:exported="true">
<intent-filter>
<action android:name="com.erfangholami.androidsolidservices.action.AUTHORIZE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
Storing grants¶
AppGrantStore keeps one grant per (package, WebID). DataStoreAppGrantStore is the
implementation over a Preferences DataStore; a value it cannot parse reads as no grants, which
refuses every call rather than allowing one.
Two lifecycle hooks matter, and forgetting either is a security bug rather than a rough edge:
- Signing an account out must call
revokeAll(webId). A grant that outlives its session comes back to life when the user signs in again. - An app being uninstalled should call
revokePackage(packageName), from a receiver forACTION_PACKAGE_FULLY_REMOVED. Ignore the replacing case, or an ordinary update drops access.
Grants are local to the device by design. Nothing about them is written to the pod; the pod
authenticates the user, and has no way to see which app on their phone made a request. The
reasoning, and why this is not Web Access Control or Access Control Policy, is in Solid Share's own
APP_ACCESS.md.
The client's side¶
A caller reaches you only if the SDK resolves your package as the host and your build carries
a signing key it accepts. HostResolver holds that list, and today it holds one entry. Adding a
second is a change to the SDK, not something a host can arrange for itself — which is the point:
binding by action alone would let any app declare the action and receive the user's data.
The key check matters because a package name is not an identity. Android will not let a second app claim the host's name while the real one is installed, but on a device where it is absent, an app sideloaded under that name would otherwise be bound to. The SDK compares the SHA-256 of the installed host's signing certificate against the digests it ships, and refuses a mismatch with a message that says so rather than a bare "not installed".
Verification is skipped when the calling app is a debug build, so you can run against a host you built locally. The flag is read from the caller, which an attacker cannot set on somebody else's release build, rather than from the host, which they could.
See App access for what each verb requires, and Architecture for how the four libraries fit together.