Vault is an Android library that simplifies persisting data from Contentful to SQLite. It defines a Java representation of your Contentful models, and at compile-time generates the corresponding database schema plus all the required boilerplate. It ships with a complementary lightweight runtime that exposes a simple ORM-like API for pulling resources back out of the generated database.
What is Contentful?
Contentful provides content infrastructure for digital teams to power websites, apps, and devices. Unlike a CMS, Contentful was built to integrate with the modern software stack. It offers a central hub for structured content, powerful management and delivery APIs, and a customizable web app that enable developers and content creators to ship their products faster.
Table of contents
- Defines a Java representation of your Contentful content model as plain classes annotated with
@ContentTypeand@Field. - Generates the corresponding SQLite schema and all persistence boilerplate at compile time via an annotation processor — no manual
SQLiteOpenHelpercode required. - Keeps your local database in sync with Contentful using the Sync API, fetching only what changed since the last call.
- A simple, ORM-like query API (
.fetch(Type.class).where(…).first()/.all()) for reading persisted resources back out. - RxJava support:
.observe(Type.class)returns anObservablefor reactive queries, andVault.observeSyncResults()reports sync completion reactively. - Multi-locale support: declare which locales to persist per
@Space, and query resources by locale. - Schema migrations via a simple
dbVersionbump on your@Spaceannotation. - Preseeding support: ship a pre-built SQLite database in your APK to avoid the cost of an initial sync on first launch.
| Requirement | Version |
|---|---|
| Java | 8 or higher |
| Android | Support depends on the contentful.java SDK version in use |
Vault depends on the contentful.java SDK for all network access to the Content Delivery API.
- Maven
<dependency>
<groupId>com.contentful.vault</groupId>
<artifactId>compiler</artifactId>
<version>3.2.12</version>
</dependency>
<dependency>
<groupId>com.contentful.vault</groupId>
<artifactId>core</artifactId>
<version>3.2.12</version>
</dependency>- Gradle
annotationProcessor 'com.contentful.vault:compiler:3.2.12'
implementation 'com.contentful.vault:core:3.2.12'Note: With 3.2.12 and older, also add
annotationProcessor 'com.contentful.vault:core:<version>'if the build fails withNoClassDefFoundError: com/contentful/vault/ContentType.
Note: Avoid 3.2.11. It was published from an older branch and is missing
SyncConfig.Builder#setLimitand#setSingleLocale(added in 3.2.9). Use 3.2.12 or newer.
Define a model, group it into a @Space, then request a sync:
@ContentType("cat")
public class Cat extends Resource {
@Field public String name;
}
@Space(
value = "cfexampleapi",
models = { Cat.class },
locales = { "en-US" }
)
public class DemoSpace { }CDAClient client = CDAClient.builder()
.setSpace("cfexampleapi")
.setToken("b4c0n73n7fu1")
.build();
Vault.with(context, DemoSpace.class).requestSync(SyncConfig.builder().setClient(client).build());Vault runs the sync on a worker thread and reflects the changes in its local database. Once complete, it broadcasts Vault.ACTION_SYNC_COMPLETE.
Models are defined by declaring a subclass of Resource. Annotate the class with @ContentType, passing the Content Type's ID as its value.
Fields are defined by annotating class attributes with @Field:
@ContentType("cat")
public class Cat extends Resource {
@Field public String name;
@Field public Cat bestFriend;
@Field public Asset image;
}By default, the attribute name is used as the field's id, but it can also be specified explicitly:
@Field("field-id-goes-here")
public String someField;Field ids are escaped automatically, but when writing a WHERE condition it's up to the caller to escape field names that collide with reserved SQL keywords:
@ContentType("...")
public class Foo extends Resource {
@Field public String order;
}Since order is a reserved SQLite keyword, a query referencing that field looks like this:
vault.fetch(Foo.class)
.where("`" + Foo$Fields.ORDER + "` = ?", "bar")
.first();Spaces are classes annotated with @Space. Specify the Space ID, an array of model classes, and an array of locale codes to persist:
@Space(
value = "cfexampleapi",
models = { Cat.class },
locales = { "en-US", "tlh" }
)
public class DemoSpace { }Once a Space is defined, invoke Vault to synchronize the local database with Contentful:
// Client.
CDAClient client = CDAClient.builder()
.setSpace("cfexampleapi")
.setToken("b4c0n73n7fu1")
.build();
// Sync.
Vault.with(context, DemoSpace.class).requestSync(SyncConfig.builder().setClient(client).build());Vault uses a worker thread to request updates from the Sync API and reflect the changes in its database. Once sync completes, Vault broadcasts Vault.ACTION_SYNC_COMPLETE.
Providing a SyncCallback invokes it once sync completes:
class SomeActivity extends Activity {
SyncCallback callback;
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Vault.with(this, DemoSpace.class).requestSync(SyncConfig.builder().setClient(client).build(), callback = new SyncCallback() {
@Override public void onResult(SyncResult result) {
if (result.isSuccessful()) {
// Success \o/
} else {
// Failure
}
}
});
}
@Override protected void onDestroy() {
Vault.cancel(callback);
super.onDestroy();
}
}Note: Extra care needs to be taken for the lifecycle — cancel the callback on lifecycle events.
SyncConfig takes either a CDAClient, or an access token and a space ID:
SyncConfig config = SyncConfig.builder()
.setAccessToken("b4c0n73n7fu1")
.setSpaceId("cfexampleapi")
.setEnvironment("master") // optional, defaults to "master"
.setLimit(500) // optional: page size of the initial sync, 1-1000
.setSingleLocale(true) // optional: store only the space's default locale
.setInvalidate(false) // optional: true wipes local data and syncs from scratch
.build();
Vault.with(context, DemoSpace.class).requestSync(config);setLimitonly applies to the first page of an initial sync. Later pages and delta syncs use the API default. Values outside 1-1000 throwIllegalArgumentException.setSingleLocale(true)stores only the default locale, and queries for any other locale return no results. Changing this setting, or opening an older database whose locale mode is unknown, makes the next sync replace all data with a fresh initial sync. Existing offline data remains available if fetching or saving the replacement fails. The locale mode and sync token are stored in the same database transaction.setInvalidate(true)replaces local data with a fresh initial sync. The old data is only removed once the new data has been downloaded, so a failed sync keeps it.
To close a space's database connection, call vault.release(). It only affects that space; Vault.releaseAll() is deprecated because it closes every space. Afterwards, get a new instance with Vault.with(...).
RxJava users can subscribe to sync results via an Observable:
Vault.observeSyncResults() // Returns Observable<SyncResult>.Vault wraps its generated database with a query API for fetching persisted objects:
Vault vault = Vault.with(context, DemoSpace.class);
// Fetch the first Cat.
vault.fetch(Cat.class).first();
// Fetch the most recently updated Cat.
vault.fetch(Cat.class)
.order(Cat$Fields.UPDATED_AT + " DESC")
.first();
// Fetch a Cat with a specific name.
vault.fetch(Cat.class)
.where(Cat$Fields.NAME + " = ?", "Nyan Cat")
.first();
// Fetch a Cat with a specific name pattern.
vault.fetch(Cat.class)
.where(Cat$Fields.NAME + " LIKE ?", "%Nyan%")
.first();
// Fetch a Cat with a specific boolean field.
// SQLite stores booleans as 0/1.
vault.fetch(Cat.class)
.where(Cat$Fields.IS_GRUMPY + " = ?", "1")
.first();
// Fetch all Cats, ordered by creation date.
vault.fetch(Cat.class)
.order(Cat$Fields.CREATED_AT)
.all();
// Fetch all Cats, using the Klingon locale.
vault.fetch(Cat.class)
.all("tlh");RxJava queries are created via .observe():
vault.observe(Cat.class)
.where(Cat$Fields.NAME + " = ?", "Happy Cat")
.all() // Returns Observable<Cat>.The example above creates an Observable that subscribes and observes on the thread that initiated the query. Chain .subscribeOn(…)/.observeOn(…) if you need a different threading model:
vault.observe(Cat.class)
.all()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())Whenever a previously used model changes, apply a migration by incrementing the version number on your @Space:
@Space(value = "cfexampleapi", models = { Cat.class }, dbVersion = 2)
public class DemoSpace { }Note: this deletes any previously persisted data and re-acquires it on the next sync.
Depending on the amount of content in a given space, initial synchronization can take some time. To avoid that cost, pre-seed the database with static content instead.
Use VaultDatabaseExporter to create the initial database file. It takes an Android Context and a Vault Space; calling .export(…) creates a SQLite database at src/main/assets/initial_seed.db. Point Vault at it as follows:
@Space(
value = "{spaceid}", // Space id of the space to use.
models = { Cat.class }, // Model classes to be used.
copyPath = "initial_seed.db" // Name of the just-created database file.
)
public class VaultSpace { }Keep the bundled database up to date by running a Robolectric test before each release, which syncs live Contentful data into the existing database file:
@RunWith(RobolectricTestRunner.class)
public class TestSeedDB {
@Test
public void testSyncDBtoSqlite() throws Exception {
final Activity activity = Robolectric.setupActivity(Activity.class);
assertTrue(new VaultDatabaseExporter().export(activity, VaultSpace.class, VaultSpace.TOKEN));
}
}If this test fails, that indicates the bundled database content is out of date; follow the guidance in the failure message.
Note: To add preseeding to an already-shipped app, increment
dbVersion— this invalidates any pre-existing content on device so the new bundled database takes effect.
Grab the ProGuard configuration file and apply it to your project.
Browse the Javadoc for the full API reference of this library. Every released change is recorded in the CHANGELOG.md.
Vault is built on top of the contentful.java SDK; consult its README for details on CDAClient and the underlying Content Delivery API.
- File an issue here on GitHub:
. Make sure to remove any credential from your code before sharing it.
We appreciate any help on our repositories. Feel free to open a pull request or an issue.
Copyright 2017 Contentful, GmbH.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
We want to provide a safe, inclusive, welcoming, and harassment-free space and experience for all participants, regardless of gender identity and expression, sexual orientation, disability, physical appearance, socioeconomic status, body size, ethnicity, nationality, level of experience, age, religion (or lack thereof), or other identity markers.
