|
23 | 23 |
|
24 | 24 | package com.algolia.search.saas;
|
25 | 25 |
|
| 26 | +import android.content.res.Resources; |
26 | 27 | import android.support.annotation.NonNull;
|
| 28 | +import android.support.annotation.Nullable; |
27 | 29 | import android.util.Log;
|
28 | 30 |
|
29 | 31 | import com.algolia.search.offline.core.LocalIndex;
|
|
35 | 37 |
|
36 | 38 | import java.io.File;
|
37 | 39 | import java.io.FileOutputStream;
|
| 40 | +import java.io.IOException; |
38 | 41 | import java.io.OutputStreamWriter;
|
39 | 42 | import java.io.UnsupportedEncodingException;
|
40 | 43 | import java.io.Writer;
|
|
83 | 86 | * NOTE: The strategy applies both to {@link #searchAsync(Query, CompletionHandler)} and
|
84 | 87 | * {@link #searchDisjunctiveFacetingAsync(Query, List, Map, CompletionHandler)}.
|
85 | 88 | *
|
| 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 | + * |
86 | 136 | * ## Limitations
|
87 | 137 | *
|
88 | 138 | * Algolia's core features are fully supported offline, including (but not limited to): **ranking**,
|
@@ -119,6 +169,7 @@ public class MirroredIndex extends Index
|
119 | 169 | private SyncStats stats;
|
120 | 170 |
|
121 | 171 | private Set<SyncListener> syncListeners = new HashSet<>();
|
| 172 | + private Set<BootstrapListener> bootstrapListeners = new HashSet<>(); |
122 | 173 |
|
123 | 174 | // ----------------------------------------------------------------------
|
124 | 175 | // Constants
|
@@ -560,6 +611,107 @@ public void run()
|
560 | 611 | }
|
561 | 612 | }
|
562 | 613 |
|
| 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 | + |
563 | 715 | // ----------------------------------------------------------------------
|
564 | 716 | // Search
|
565 | 717 | // ----------------------------------------------------------------------
|
@@ -1076,6 +1228,8 @@ private JSONObject _browseMirror(@NonNull Query query) throws AlgoliaException
|
1076 | 1228 | // Listeners
|
1077 | 1229 | // ----------------------------------------------------------------------
|
1078 | 1230 |
|
| 1231 | + // SyncListener |
| 1232 | + |
1079 | 1233 | /**
|
1080 | 1234 | * Add a listener for sync events.
|
1081 | 1235 | * @param listener The listener to add.
|
@@ -1107,4 +1261,34 @@ private void fireSyncDidFinish()
|
1107 | 1261 | listener.syncDidFinish(this, error, stats);
|
1108 | 1262 | }
|
1109 | 1263 | }
|
| 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 | + } |
1110 | 1294 | }
|
0 commit comments