This project contains the Cyface Android SDK which is used by Cyface applications to capture data on Android devices.
This library is published to the Github Package Registry.
To use it as a dependency in your app you need to:
-
Make sure you are authenticated to the repository:
-
You need a Github account with read-access to this Github repository
-
Create a personal access token on Github with "read:packages" permissions
-
Create or adjust a
gradle.properties
file in the project root containing:githubUser=YOUR_USERNAME githubToken=YOUR_ACCESS_TOKEN
-
Add the custom repository to your app’s
build.gradle
:
repositories { // Other maven repositories, e.g.: google() mavenCentral() gradlePluginPortal() // Repository for this library maven { url = uri("https://maven.pkg.github.com/cyface-de/android-backend") credentials { username = project.findProperty("githubUser") password = project.findProperty("githubToken") } } }
-
-
Add this package as a dependency to your app’s
build.gradle
:dependencies { implementation "de.cyface:datacapturing:$cyfaceAndroidBackendVersion" implementation "de.cyface:synchronization:$cyfaceAndroidBackendVersion" implementation "de.cyface:persistence:$cyfaceAndroidBackendVersion" }
-
Set the
$cyfaceAndroidBackendVersion
gradle variable to the latest version.
This SDK is compatible with our Data Collector Version 5.
The following steps are required before you can start coding.
The SDK provides the CyfaceAuthenticatorService and CyfaceSyncService which authenticates with and uploads data to a Cyface Collector API. The SDK implementing app can also implement services for different APIs.
Register the Authenticator- and Sync service which should be called by the system in the SDK
implementing app’s AndroidManifest.xml
, e.g. for the default implementations:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service android:name="de.cyface.synchronization.CyfaceAuthenticatorService"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name="de.cyface.synchronization.CyfaceSyncService"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
</application>
</manifest>
This SDK uses Android’s SyncAdapter
to sync data. The StubProvider
is a ContentProvider
stub
which the SyncAdapter
requires, even when we don’t use it to access the data of this SDK.
Therefor, you need to set a provider and to make sure you use the same provider everywhere:
-
The
AndroidManifest.xml
is required to override the default content provider as declared by the persistence project. This needs to be done by each SDK integrating application separately.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- This overwrites the provider in the SDK. This way the app can be installed next to other
SDK using apps. The "authorities" must match the one in your AndroidManifest.xml! -->
<provider
android:name="de.cyface.persistence.content.StubProvider"
android:authorities="your.domain.app.provider"
android:exported="false"
android:process=":persistence_process"
android:syncable="true"
tools:replace="android:authorities" />
</application>
</manifest>
-
Define your authority which you must use as parameter in
new Cyface-/CustomDataCapturingService()
(see sample below). This must be the same as defined in theAndroidManifest.xml
above.
public class Constants {
public final static String AUTHORITY = "your.domain.app.provider"; // replace this
}
-
Create a resource file
src/main/res/xml/sync_adapter.xml
and use the same provider:
<?xml version="1.0" encoding="UTF-8" ?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="your.domain.app.provider"
android:accountType="your.domain.app"
android:userVisible="false"
android:supportsUploading="true"
android:allowParallelSyncs="false"
android:isAlwaysSyncable="true" />
The core of our SDK is the DataCapturingService
which controls the capturing process.
We provide a default interface for this service: CyfaceDataCapturingService
.
Unless you need a custom DataCapturingService
extension, use this one.
Note
|
This documentation is out of date as it describes a former extension MovebisDataCapturingService
in the samples but the interface for CyfaceDataCapturingService is mostly the same.
|
The following steps are required to communicate with this service.
These instructions assume a DataCapturingButton
is used to display the current capturing status
and to control the capture status.
This interface allows us to inject your custom strategies into our SDK.
To continuously run an Android service, without the system killing said service, it needs to show a notification to the user in the Android status bar.
The Cyface data capturing runs as such a service and thus needs to display such a notification.
Applications using the Cyface SDK may configure style and behaviour of this notification by
providing an implementation of de.cyface.datacapturing.EventHandlingStrategy
to the constructor
of the de.cyface.datacapturing.DataCapturingService
.
An example implementation is provided by de.cyface.datacapturing.IgnoreEventsStrategy
.
The most important step is to implement the method
de.cyface.datacapturing.EventHandlingStrategy#buildCapturingNotification(DataCapturingBackgroundService)
.
This can look like:
public class EventHandlingStrategyImpl implements EventHandlingStrategy {
@Override
public @NonNull Notification buildCapturingNotification(final @NonNull DataCapturingBackgroundService context) {
final String channelId = "channel";
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O && notificationManager.getNotificationChannel(channelId)==null) {
final NotificationChannel channel = new NotificationChannel(channelId, "Cyface Data Capturing", NotificationManager.IMPORTANCE_DEFAULT);
notificationManager.createNotificationChannel(channel);
}
return new NotificationCompat.Builder(context, channelId)
.setContentTitle("Cyface")
.setSmallIcon(R.drawable.your_icon) // see "attention" notes below
.setContentText("Running Data Capturing")
.setOngoing(true)
.setAutoCancel(false)
.build();
}
}
Further details about how to create a proper notification are available via the Google developer documentation.
The most likely adaptation an application using the Cyface SDK for Android should do, is use the android.app.Notification.Builder.setContentIntent(PendingIntent)
to call the applications main activity if the user presses the notification.
ATTENTION:
-
Service notifications require an application wide unique identifier. This identifier is 74.656. Due to limitations in the Android framework, this is not configurable. You must not use the same notification identifier for any other notification displayed by your app!
-
If you want to use a vector xml drawable as Notification icon make sure to do the following:
Even with
vectorDrawables.useSupportLibrary
enabled the vector drawable won’t work as a notification icon (notificationBuilder.setSmallIcon()
) on devices with API < 21. We assume that’s because of the way we need to inject your custom notification. A simple fix is to have the xml inres/drawable-anydpi-v21/icon.xml
and to generate notification icon PNGs under the same resource name in the usual paths (res/drawable-**dpi/icon.png
).
To save resources your should create your service when the view is created and reuse this instance when you need to communicate with it.
class MainFragment extends Fragment {
private MovebisDataCapturingService dataCapturingService;
private DataCapturingButton dataCapturingButton;
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
final static int SENSOR_FREQUENCY = 100;
dataCapturingService = new MovebisDataCapturingService(context,
uiListener, locationUpdateRate, eventHandlingStrategy, capturingListener, SENSOR_FREQUENCY);
}
// Depending on your implementation you need to register the DataCapturingService in your DataCapturingButton:
@Override
public void onResume() {
super.onResume();
// If you want to receive events for the synchronization status
dataCapturingService.addConnectionStatusListener(this);
dataCapturingButton.onResume(dataCapturingService);
}
// If you registered to receive events for the synchronization status
@Override
public void onPause() {
dataCapturingService.removeConnectionStatusListener(this);
super.onPause();
}
@Override
public void onDestroyView() {
try {
// As required by the `WiFiSurveyor.startSurveillance()`
dataCapturingService.shutdownDataCapturingService();
} catch (SynchronisationException e) {
Log.w(TAG, "Failed to shut down CyfaceDataCapturingService. ", e);
}
// If you registered to receive events for the synchronization status
dataCapturingService.removeConnectionStatusListener(this);
super.onDestroyView();
}
}
When your UI resumes you need to reconnect to your service:
The reconnect()
method returns true when there was a capturing running during reconnect.
This way we can use the isRunning()
result from within reconnect()
and avoid duplicate
isRunning()
calls.
public class DataCapturingButton implements DataCapturingListener {
PersistenceLayer<DefaultPersistenceBehaviour> persistence =
new DefaultPersistenceLayer<>(context, new DefaultPersistenceBehaviour());
public void onResume(@NonNull final CyfaceDataCapturingService dataCapturingService) {
this.dataCapturingService = dataCapturingService;
dataCapturingService.addDataCapturingListener(this);
if (dataCapturingService.reconnect(IS_RUNNING_CALLBACK_TIMEOUT)) {
// Your logic, e.g.:
setButtonStatus(button, OPEN);
} else {
// Attention: reconnect() only returns true if there is an OPEN measurement
// To check for PAUSED measurements use the persistence layer.
if (persistenceLayer.hasMeasurement(PAUSED)) {
// Your logic, e.g.:
setButtonStatus(button, PAUSED);
} else {
// Your logic, e.g.:
setButtonStatus(button, FINISHED);
}
}
}
public void onPause() {
dataCapturingService.removeDataCapturingListener(this);
}
@Override
public void onDestroyView() {
// Unbinds the services. They continue to run in the background but won't send any updates to this button.
if (dataCapturingService != null) {
try {
dataCapturingService.disconnect();
} catch (DataCapturingException e) {
// This just tells us there is no running capturing in the background, see [MOV-588]
Log.d(TAG, "No need to unbind as the background service was not running.");
}
}
}
}
This is only required for CyfaceDataCapturingService
.
Define which Activity should be launched to request the user to log in:
public class CustomApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
CyfaceAuthenticator.LOGIN_ACTIVITY = LoginActivity.class;
}
}
We use DataStore to store user preferences.
Initialize these settings exactly once per file per process, for UI process:
class CustomApplication : Application() {
/**
* The settings used by both, UIs and libraries.
*/
private val lazyAppSettings by lazy { // android-utils
AppSettings(this)
}
override fun onCreate() {
super.onCreate()
// Initialize DataStore once for all settings
appSettings = lazyAppSettings
TrackingSettings.initialize(this) // energy_settings
CyfaceAuthenticator.settings = DefaultSynchronizationSettings( // synchronization
this,
"https://example.com/api/v4", // Set the Data Collector URL
// Movebis variant can replace oauth config with `JsonObject()`
OAuth2.oauthConfig(BuildConfig.oauthRedirect, BuildConfig.oauthDiscovery)
)
}
}
This is only required for CyfaceDataCapturingService
.
Create an account for synchronization and start WifiSurveyor
:
public class MainFragment extends Fragment implements ConnectionStatusListener {
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
try {
// dataCapturingService = ... - see above
// Needs to be called after `new CyfaceDataCapturingService()`
startSynchronization(context);
// If you want to receive events for the synchronization status
dataCapturingService.addConnectionStatusListener(this);
} catch (final SetupException | CursorIsNullException e) {
throw new IllegalStateException(e);
}
}
@SuppressWarnings("WeakerAccess")
public void startSynchronization(final Context context) {
final AccountManager accountManager = AccountManager.get(context);
final boolean validAccountExists = accountWithTokenExists(accountManager);
if (validAccountExists) {
try {
dataCapturingService.startWifiSurveyor();
} catch (SetupException e) {
throw new IllegalStateException(e);
}
return;
}
// Login via LoginActivity, create account and using dynamic tokens
// The LoginActivity is called by Android which handles the account creation
accountManager.addAccount(ACCOUNT_TYPE, AUTH_TOKEN_TYPE, null, null,
getMainActivityFromContext(context), new AccountManagerCallback<Bundle>() {
@Override
public void run(AccountManagerFuture<Bundle> future) {
try {
// noinspection unused - this allows us to detect when LoginActivity is closed
final Bundle bundle = future.getResult();
// The LoginActivity created a temporary account which cannot yet be used for synchronization.
// As the login was successful we now register the account correctly:
final AccountManager accountManager = AccountManager.get(context);
final Account account = accountManager.getAccountsByType(ACCOUNT_TYPE)[0];
dataCapturingService.getWifiSurveyor().makeAccountSyncable(account, syncEnabledPreference);
dataCapturingService.startWifiSurveyor();
} catch (OperationCanceledException e) {
// This closes the app when the LoginActivity is closed
getMainActivityFromContext(context).finish();
} catch (AuthenticatorException | IOException | SetupException e) {
throw new IllegalStateException(e);
}
}
}, null);
}
private static boolean accountWithTokenExists(final AccountManager accountManager) {
final Account[] existingAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE);
Validate.isTrue(existingAccounts.length < 2, "More than one account exists.");
return existingAccounts.length != 0
&& accountManager.peekAuthToken(existingAccounts[0], AUTH_TOKEN_TYPE) != null;
}
}
This interface informs your app about data capturing events. Implement the interface to update your UI depending on these events.
Note
|
Please use This way the database access is reduced which is especially important when executing this frequently, like in the example below - on each location update. |
Here is a basic example implementation.
class DataCapturingButton implements DataCapturingListener {
@Override
public void onNewGeoLocationAcquired(GeoLocation geoLocation) {
// To identify invalid ("unclean") location, check geoLocation.isValid()
// Load updated measurement distance
final Measurement measurement;
try {
measurement = dataCapturingService.loadCurrentlyCapturedMeasurement();
} catch (final NoSuchMeasurementException | CursorIsNullException e) {
throw new IllegalStateException(e);
}
final double distance = measurement.getDistance();
// Your logic, e.g. update the UI with the current distance
}
// The other interface methods
}
Now you can actually use the DataCapturingService
instance to capture data.
To capture a measurement you need to start the capturing and stop it after some time:
public class DataCapturingButton implements DataCapturingListener {
public void onClick(View view) {
dataCapturingService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, new IsRunningCallback() {
@Override
public void isRunning() {
Validate.isTrue(buttonStatus == OPEN, "DataCapturingButton is out of sync.");
stopCapturing();
}
@Override
public void timedOut() {
Validate.isTrue(buttonStatus != OPEN, "DataCapturingButton is out of sync.");
try {
// If Measurement is paused, resume the measurement on a normal click
if (persistenceLayer.hasMeasurement(PAUSED)) {
resumeCapturing();
return;
}
startCapturing();
} catch (final CursorIsNullException e) {
throw new IllegalStateException(e);
}
}
});
}
private void startCapturing() {
dataCapturingService.start(Modality.BICYCLE, new StartUpFinishedHandler(
MessageCodes.getServiceStartedActionId(context.getPackageName())) {
@Override
public void startUpFinished(final long measurementIdentifier) {
// Your logic, e.g.:
setButtonStatus(button, OPEN);
}
});
}
private void stopCapturing() {
dataCapturingService.stop(new ShutDownFinishedHandler(MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) {
@Override
public void shutDownFinished(final long measurementIdentifier) {
// Your logic, e.g.:
setButtonStatus(button, FINISHED);
setButtonEnabled(true);
}
});
}
@Overwrite
public void onCapturingStopped() {
setButtonStatus(button, FINISHED);
}
}
If you want to pause a measurement you can use:
public class DataCapturingButton implements DataCapturingListener {
public void onLongClick(View view) {
dataCapturingService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, new IsRunningCallback() {@Override
public void isRunning() {
Validate.isTrue(buttonStatus == OPEN, "DataCapturingButton is out of sync.");
pauseCapturing();
}
@Override
public void timedOut() {
Validate.isTrue(buttonStatus != OPEN, "DataCapturingButton is out of sync.");
try {
// If Measurement is paused, stop the measurement on long press
if (persistenceLayer.hasMeasurement(PAUSED)) {
stopCapturing();
return;
}
startCapturing();
} catch (final CursorIsNullException e) {
throw new IllegalStateException(e);
}
}
});
return true;
}
private void pauseCapturing() {
dataCapturingService.pause(new ShutDownFinishedHandler(MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) {
@Override
public void shutDownFinished(final long measurementIdentifier) {
// Your logic, e.g.:
setButtonStatus(button, PAUSED);
setButtonEnabled(true);
}
});
}
private void resumeCapturing() {
dataCapturingService.resume(new StartUpFinishedHandler(MessageCodes.getServiceStartedActionId(context.getPackageName())) {
@Override
public void startUpFinished(final long measurementIdentifier) {
setButtonStatus(button, OPEN);
}
});
}
}
You now need to use the DefaultPersistenceLayer
to access and control captured measurement data.
class measurementControlOrAccessClass {
PersistenceLayer<DefaultPersistenceBehaviour> persistence =
new DefaultPersistenceLayer<>(context, new DefaultPersistenceBehaviour());
}
-
Use
persistenceLayer.loadMeasurement(mid)
to load a specific measurement -
Use
loadMeasurements()
orloadMeasurements(MeasurementStatus)
to load multiple measurements (of a specific state)
Loaded Measurement
s contain details, e.g. the Measurement Distance.
Note
|
The attributes of a Measurement which is not yet finished change over time so you need to make sure you reload it. You can find an example for this in Implement Data Capturing Listener. |
Finished measurements are measurements which are stopped (i.e. not paused or ongoing).
class measurementControlOrAccessClass {
void loadMeasurements() {
persistence.loadMeasurements(MeasurementStatus.FINISHED);
}
}
The loadTracks()
method returns a chronologically ordered list of Track
s.
Each time a measurement is paused and resumed, a new Track
is started for the same measurement.
A Track
contains the chronologically ordered ParcelableGeoLocation
s captured.
You can either load the raw track or a "cleaned" version of it. See the DefaultLocationCleaning
class for details.
class measurementControlOrAccessClass {
void loadTrack() {
// Raw track:
List<Track> tracks = persistence.loadTracks(measurementId);
// or, "cleaned" track:
List<Track> tracks = persistence.loadTracks(measurementId, new DefaultLocationCleaningStrategy());
//noinspection StatementWithEmptyBody
if (tracks.size() > 0 ) {
// your logic
}
}
}
To display the distance for an ongoing measurement (which is updated about once per second) you need to call
dataCapturingService.loadCurrentlyCapturedMeasurement()
regularly, e.g. on each location update to always have the most recent information.
For this you need to implement the DataCapturingListener
interface to be notified on onNewGeoLocationAcquired(GeoLocation)
events.
See Implement Data Capturing Listener for sample code.
To delete the measurement data stored on the device for finished or synchronized measurements use:
class measurementControlOrAccessClass {
void deleteMeasurement(final long measurementId) {
// To make sure you don't delete the ongoing measurement because this leads to an exception
Measurement currentlyCapturedMeasurement;
try {
currentlyCapturedMeasurement = persistenceLayer.loadCurrentlyCapturedMeasurement();
} catch (NoSuchMeasurementException e) {
// do nothing
}
if (currentlyCapturedMeasurement == null || currentlyCapturedMeasurement.getIdentifier() != measurementId) {
new DeleteFromDBTask()
.execute(new DeleteFromDBTaskParams(persistenceLayer, this, measurementId));
} else {
Log.d(TAG, "Not deleting currently captured measurement: " + measurementId);
}
}
private static class DeleteFromDBTaskParams {
final PersistenceLayer<DefaultPersistenceBehaviour> persistenceLayer;
final long measurementId;
DeleteFromDBTaskParams(final DefaultPersistenceLayer<DefaultPersistenceBehaviour> persistenceLayer,
final long measurementId) {
this.persistenceLayer = persistenceLayer;
this.measurementId = measurementId;
}
}
private class DeleteFromDBTask extends AsyncTask<DeleteFromDBTaskParams, Void, Void> {
protected Void doInBackground(final DeleteFromDBTaskParams... params) {
final PersistenceLayer<DefaultPersistenceBehaviour> persistenceLayer = params[0].persistenceLayer;
final long measurementId = params[0].measurementId;
persistenceLayer.delete(measurementId);
}
protected void onPostExecute(Void v) {
// Your logic
}
}
}
The loadEvents()
method returns a chronologically ordered list of Event
s.
These Events log Measurement
related interactions of the user, e.g.:
-
EventType.LIFECYCLE_START, EventType.LIFECYCLE_PAUSE, EventType.LIFECYCLE_RESUME, EventType.LIFECYCLE_STOP whenever a user starts, pauses, resumes or stops the Measurement.
-
EventType.MODALITY_TYPE_CHANGE at the start of a Measurement to define the Modality used in the Measurement and when the user selects a new
Modality
type during an ongoing (or paused) Measurement. The later is logged whenpersistenceLayer.changeModalityType(Modality newModality)
is called with a different Modality than the current one. -
The
Event
class contains agetValue()
attribute which contains thenewModality
in case of aEventType.MODALITY_TYPE_CHANGE
or elseNull
class measurementControlOrAccessClass {
void loadEvents() {
// To retrieve all Events of that Measurement
//noinspection UnusedAssignment
List<Event> events = persistence.loadEvents(measurementId);
// Or to retrieve only the Events of a specific EventType
events = persistence.loadEvents(measurementId, EventType.MODALITY_TYPE_CHANGE);
//noinspection StatementWithEmptyBody
if (events.size() > 0 ) {
// your logic
}
}
}
This section is only relevant for developers of this library.
The SDK contains the following models:
The DataCapturingService
allows to control data capturing, persists the data & informs about the capturing progress.
The PersistenceLayer
serves as the data layer for SDK implementing apps.
The sub-package model
contains the data types persisted.
The following data types are persisted in an SQLite database using the Room API.
-
Identifier: The device identifier
-
Measurement: The data collected between capturing
start
andstop
-
Event: Life-cycle changes (
start
/pause
/resume
/stop
) ormodality
changes for one measurement -
Location: GNSS data captured for one measurement
-
Pressure: Barometer data captured fro one measurement
The following data types are persisted in the Cyface Binary Format using Protobuf and stored in the file system:
-
Accelerometer:
*.cyfa
files -
Gyroscope:
*.cyfr
files -
Magnetometer:
*.cyfd
files
The sub-package dao
contains the local data sources for the data types above.
The sub-package repository
contains the abstraction layer for data sources, allowing multiple data sources per type with a common interface (e.g. network and database/file (dao
)).
The sub-package serialization
contains the functionality to serialize all data of one measurement
into a compressed *.ccyf
file which can be uploaded to the Cyface Collector.
The sub-package content
implements a ContentProvider
to allow Synchronization’s `SyncAdapter
to access and upload data.
The CyfaceSyncService
uploads measurements to the Cyface Collector & informs about the upload progress.
-
cyfaceAndroidBackendVersion
in rootbuild.gradle
is automatically set by the CI -
Just tag the release and push the tag to Github
-
The Github package is automatically published when a new version is tagged and pushed by our Github Actions to the Github Registry
-
The tag is automatically marked as a 'new Release' on Github
The AVD Cache leads to Install_failed_Update_Incompatible
after a few builds.
- we opened an issue here: ReactiveCircus/android-emulator-runner#319
- we could try to make the AVD cache only be used on main branch like
- see https://github.com/ankidroid/Anki-Android/pull/11032/files?diff=split&w=0
- but for now, we just disabled the AVD cache for the CI to be usable
Copyright 2017-2023 Cyface GmbH
This file is part of the Cyface SDK for Android.
The Cyface SDK for Android is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
The Cyface SDK for Android is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with the Cyface SDK for Android. If not, see http://www.gnu.org/licenses/.