Skip to content
This repository was archived by the owner on Jan 31, 2022. It is now read-only.

Commit f069a94

Browse files
author
Clément Le Provost
committed
[offline] Support bootstrapping mirrored indices from local files/resources
1 parent 48c1573 commit f069a94

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2012-2016 Algolia
3+
* http://www.algolia.com/
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
24+
package com.algolia.search.saas;
25+
26+
import android.support.annotation.NonNull;
27+
28+
/**
29+
* Listener for events related to index bootstrapping.
30+
*
31+
* Notifications are sent on a per-index basis, but you may register the same listener for all indices.
32+
* Notifications are sent on the main thread.
33+
*/
34+
public interface BootstrapListener
35+
{
36+
/**
37+
* Bootstrapping has just started.
38+
*
39+
* @param index The bootstrapping index.
40+
*/
41+
public void bootstrapDidStart(@NonNull MirroredIndex index);
42+
43+
/**
44+
* Bootstrapping has just finished.
45+
*
46+
* @param index The bootstrapping index.
47+
* @param error Null if success, otherwise indicates the error.
48+
*/
49+
public void bootstrapDidFinish(@NonNull MirroredIndex index, Throwable error);
50+
}

algoliasearch/src/offline/java/com/algolia/search/saas/FileUtils.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
import android.support.annotation.NonNull;
2727

2828
import java.io.File;
29+
import java.io.FileOutputStream;
30+
import java.io.IOException;
31+
import java.io.InputStream;
2932

3033
/**
3134
* Various filesystem-related utilities.
@@ -49,4 +52,26 @@ public static boolean deleteRecursive(@NonNull File item)
4952
ok = ok && item.delete();
5053
return ok;
5154
}
55+
56+
/**
57+
* Write a stream of bytes to a file.
58+
*
59+
* @param destinationFile The file to be written to. The parent directory must exist. If the file already exists,
60+
* it will be overwritten.
61+
* @param content The bytes to write.
62+
* @throws IOException if anything goes wrong.
63+
*/
64+
public static void writeFile(@NonNull File destinationFile, @NonNull InputStream content) throws IOException {
65+
byte[] buffer = new byte[64 * 1024]; // 64 kB buffer
66+
FileOutputStream outputStream = new FileOutputStream(destinationFile);
67+
try {
68+
int bytesRead;
69+
while ((bytesRead = content.read(buffer)) >= 0) {
70+
outputStream.write(buffer, 0, bytesRead);
71+
}
72+
} finally {
73+
content.close();
74+
outputStream.close();
75+
}
76+
}
5277
}

algoliasearch/src/offline/java/com/algolia/search/saas/MirroredIndex.java

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323

2424
package com.algolia.search.saas;
2525

26+
import android.content.res.Resources;
2627
import android.support.annotation.NonNull;
28+
import android.support.annotation.Nullable;
2729
import android.util.Log;
2830

2931
import com.algolia.search.offline.core.LocalIndex;
@@ -35,6 +37,7 @@
3537

3638
import java.io.File;
3739
import java.io.FileOutputStream;
40+
import java.io.IOException;
3841
import java.io.OutputStreamWriter;
3942
import java.io.UnsupportedEncodingException;
4043
import java.io.Writer;
@@ -83,6 +86,53 @@
8386
* NOTE: The strategy applies both to {@link #searchAsync(Query, CompletionHandler)} and
8487
* {@link #searchDisjunctiveFacetingAsync(Query, List, Map, CompletionHandler)}.
8588
*
89+
*
90+
* ## Bootstrapping
91+
*
92+
* Before the first sync has successfully completed, a mirrored index is not available offline, because it has simply
93+
* no data to search in yet. In most cases, this is not a problem: the app will sync as soon as possible, so unless
94+
* the device is offline when the app is started for the first time, or unless search is required right after the
95+
* first launch, the user should not notice anything.
96+
*
97+
* However, in some cases, you might need to have offline data available as soon as possible. To achieve that,
98+
* `MirroredIndex` provides a **bootstrapping** feature.
99+
*
100+
* Bootstrapping consists in prepackaging with your app the data necessary to build your index, in JSON format;
101+
* namely:
102+
*
103+
* - settings (one file)
104+
* - objects (as many files as needed, each containing an array of objects)
105+
*
106+
* Then, upon application startup, call one of the `bootstrap*` methods. This will check if data already exists for the
107+
* mirror and, if not, populate the mirror with the provided data. It also guarantees that a sync will not be started
108+
* in the meantime, thus avoiding race conditions.
109+
*
110+
* ### Discussion
111+
*
112+
* **Warning:** We strongly advise against prepackaging index files. While it may work in some cases, Algolia Offline
113+
* makes no guarantee whatsoever that the index file format will remain backward-compatible forever, nor that it
114+
* is independent of the hardware architecture (e.g. 32 bits vs 64 bits, or Little Endian vs Big Endian). Instead,
115+
* always use the official bootstrapping feature.
116+
*
117+
* While bootstrapping involves building the offline index on the device, and therefore incurs a small delay before
118+
* the mirror is actually usable, using plain JSON offers a few advantages compared to prepackaging the index file
119+
* itself:
120+
*
121+
* - You only need to ship the raw object data, which is smaller than shipping an entire index file, which contains
122+
* both the raw data *and* indexing metadata.
123+
*
124+
* - Plain JSON compresses well with standard compression techniques like GZip, whereas an index file has a binary
125+
* format which doesn't compress very efficiently.
126+
*
127+
* - Build automation is facilitated: you can easily extract the required data from your back-end, whereas building
128+
* an index would involve running the app on each mobile platform as part of your build process and capturing the
129+
* filesystem.
130+
*
131+
* Also, the build process is purposedly single-threaded across all indices, which means that on most modern devices
132+
* with multi-core CPUs, the impact of bootstrapping on the app's performance will be very moderate, especially
133+
* regarding UI responsiveness.
134+
*
135+
*
86136
* ## Limitations
87137
*
88138
* Algolia's core features are fully supported offline, including (but not limited to): **ranking**,
@@ -119,6 +169,7 @@ public class MirroredIndex extends Index
119169
private SyncStats stats;
120170

121171
private Set<SyncListener> syncListeners = new HashSet<>();
172+
private Set<BootstrapListener> bootstrapListeners = new HashSet<>();
122173

123174
// ----------------------------------------------------------------------
124175
// Constants
@@ -560,6 +611,107 @@ public void run()
560611
}
561612
}
562613

614+
// ----------------------------------------------------------------------
615+
// Bootstrapping
616+
// ----------------------------------------------------------------------
617+
618+
/**
619+
* Bootstrap the local mirror with local data stored on the filesystem.
620+
*
621+
* **Note:** This method will do nothing if offline data is already available, making it safe to call at every
622+
* application launch.
623+
*
624+
* @param settingsFile Absolute path to the file containing the index settings, in JSON format.
625+
* @param objectFiles Absolute path(s) to the file(s) containing the objects. Each file must contain an array of
626+
* objects, in JSON format.
627+
*/
628+
public void bootstrapFromFiles(@NonNull final File settingsFile, @NonNull final File... objectFiles) {
629+
getClient().localBuildExecutorService.submit(new Runnable() {
630+
@Override
631+
public void run() {
632+
// Abort immediately if data already exists.
633+
if (localIndex.exists()) {
634+
return;
635+
}
636+
_bootstrap(settingsFile, objectFiles);
637+
}
638+
});
639+
}
640+
641+
/**
642+
* Bootstrap the local mirror with local data stored in raw Android resources.
643+
*
644+
* **Note:** This method will do nothing if offline data is already available, making it safe to call at every
645+
* application launch.
646+
*
647+
* @param resources A {@link Resources} instance to read resources from.
648+
* @param settingsResId Resource identifier of the index settings, in JSON format.
649+
* @param objectsResIds Resource identifiers of the various objects files. Each file must contain an array of
650+
* objects, in JSON format.
651+
*/
652+
public void bootstrapFromRawResources(@NonNull final Resources resources, @NonNull final int settingsResId, @NonNull final int... objectsResIds) {
653+
getClient().localBuildExecutorService.submit(new Runnable() {
654+
@Override
655+
public void run() {
656+
// Abort immediately if data already exists.
657+
if (localIndex.exists()) {
658+
return;
659+
}
660+
// Save resources to independent files on disk.
661+
// TODO: See if we can have the Offline Core read directly from resources or assets.
662+
File tmpDir = new File(getClient().getTempDir(), UUID.randomUUID().toString());
663+
try {
664+
tmpDir.mkdirs();
665+
// Settings.
666+
File settingsFile = new File(tmpDir, "settings.json");
667+
FileUtils.writeFile(settingsFile, resources.openRawResource(settingsResId));
668+
// Objects.
669+
File[] objectFiles = new File[objectsResIds.length];
670+
for (int i = 0; i < objectsResIds.length; ++i) {
671+
objectFiles[i] = new File(tmpDir, "objects#" + Integer.toString(objectsResIds[i]) + ".json");
672+
FileUtils.writeFile(objectFiles[i], resources.openRawResource(objectsResIds[i]));
673+
}
674+
// Build the index.
675+
_bootstrap(settingsFile, objectFiles);
676+
} catch (IOException e) {
677+
Log.e(MirroredIndex.class.getSimpleName(), "Failed to write bootstrap resources to disk", e);
678+
} finally {
679+
// Delete temporary files.
680+
FileUtils.deleteRecursive(tmpDir);
681+
}
682+
}
683+
});
684+
}
685+
686+
private void _bootstrap(@NonNull File settingsFile, @NonNull File... objectFiles) {
687+
// Notify listeners.
688+
getClient().mainHandler.post(new Runnable() {
689+
@Override
690+
public void run() {
691+
fireBootstrapDidStart();
692+
}
693+
});
694+
695+
// Build the index.
696+
String[] objectFilePaths = new String[objectFiles.length];
697+
for (int i = 0; i < objectFiles.length; ++i) {
698+
objectFilePaths[i] = objectFiles[i].getAbsolutePath();
699+
}
700+
final int status = localIndex.build(settingsFile.getAbsolutePath(), objectFilePaths, true /* clearIndex */, null /* deletedObjectIDs */);
701+
702+
// Notify listeners.
703+
getClient().mainHandler.post(new Runnable() {
704+
@Override
705+
public void run() {
706+
Throwable error = null;
707+
if (status != 200) {
708+
error = new AlgoliaException(String.format("Failed to bootstrap index \"%s\"", MirroredIndex.this.getIndexName()), status);
709+
}
710+
fireBootstrapDidFinish(error);
711+
}
712+
});
713+
}
714+
563715
// ----------------------------------------------------------------------
564716
// Search
565717
// ----------------------------------------------------------------------
@@ -1076,6 +1228,8 @@ private JSONObject _browseMirror(@NonNull Query query) throws AlgoliaException
10761228
// Listeners
10771229
// ----------------------------------------------------------------------
10781230

1231+
// SyncListener
1232+
10791233
/**
10801234
* Add a listener for sync events.
10811235
* @param listener The listener to add.
@@ -1107,4 +1261,34 @@ private void fireSyncDidFinish()
11071261
listener.syncDidFinish(this, error, stats);
11081262
}
11091263
}
1264+
1265+
// BootstrapListener
1266+
1267+
/**
1268+
* Add a listener for bootstrapping events.
1269+
* @param listener The listener to add.
1270+
*/
1271+
public void addBootstrapListener(@NonNull BootstrapListener listener) {
1272+
bootstrapListeners.add(listener);
1273+
}
1274+
1275+
/**
1276+
* Remove a listener for bootstrapping events.
1277+
* @param listener The listener to remove.
1278+
*/
1279+
public void removeBootstrapListener(@NonNull BootstrapListener listener) {
1280+
bootstrapListeners.remove(listener);
1281+
}
1282+
1283+
private void fireBootstrapDidStart() {
1284+
for (BootstrapListener listener : bootstrapListeners) {
1285+
listener.bootstrapDidStart(this);
1286+
}
1287+
}
1288+
1289+
private void fireBootstrapDidFinish(@Nullable Throwable error) {
1290+
for (BootstrapListener listener : bootstrapListeners) {
1291+
listener.bootstrapDidFinish(this, error);
1292+
}
1293+
}
11101294
}

0 commit comments

Comments
 (0)