зеркало из https://github.com/mozilla/Jisort.git
Bootsrapping Tutorials.
This commit is contained in:
Родитель
f6dc4fd934
Коммит
c8124e61e2
|
@ -6,29 +6,71 @@ android {
|
|||
buildToolsVersion "23.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.mozilla.hackathon.kiboko"
|
||||
version "${version_code}"
|
||||
versionName "${version_name}"
|
||||
minSdkVersion 14
|
||||
targetSdkVersion 23
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
|
||||
|
||||
buildConfigField("boolean", "ENABLE_DEBUG_TOOLS", "false")
|
||||
buildConfigField("String", "BOOTSTRAP_DATA_TIMESTAMP", "\"${bootstrap_data_timestamp}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("../android/debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
minifyEnabled false
|
||||
signingConfig signingConfigs.debug
|
||||
buildConfigField("boolean", "ENABLE_DEBUG_TOOLS", "true")
|
||||
}
|
||||
qualityassurance {
|
||||
debuggable true
|
||||
minifyEnabled true
|
||||
signingConfig signingConfigs.debug
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), file('proguard-project.txt')
|
||||
buildConfigField("boolean", "ENABLE_DEBUG_TOOLS", "true")
|
||||
}
|
||||
release {
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
// No signing config as we do this separately.
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), file('proguard-project.txt')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
// Event pubSub
|
||||
compile 'com.squareup:otto:1.3.8'
|
||||
compile "com.android.support:appcompat-v7:+"
|
||||
compile "com.android.support:cardview-v7:+"
|
||||
|
||||
compile files('../third_party/basic-http-client/libs/basic-http-client-android-0.88.jar')
|
||||
// JSON utility library.
|
||||
compile 'com.google.code.gson:gson:2.3'
|
||||
compile 'com.android.support:support-v4:23.4.0'
|
||||
compile 'com.android.support:design:23.4.0'
|
||||
compile 'com.github.douglasjunior:android-simple-tooltip:0.1.1'
|
||||
compile 'com.google.android.gms:play-services-analytics:9.0.0'
|
||||
compile 'com.google.guava:guava:18.0'
|
||||
}
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.mozilla.hackathon.kiboko" >
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.mozilla.hackathon.kiboko"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0.0"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name="com.mozilla.hackathon.kiboko.App"
|
||||
android:theme="@style/AppTheme"
|
||||
|
@ -123,6 +130,37 @@
|
|||
|
||||
<service android:name="com.mozilla.hackathon.kiboko.services.ChatHeadService" >
|
||||
</service>
|
||||
|
||||
<!-- Data, sync and schedule editing components -->
|
||||
|
||||
<provider
|
||||
android:name=".provider.DsoProvider"
|
||||
android:authorities="com.mozilla.hackathon.kiboko"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:syncable="true"
|
||||
android:writePermission="com.google.samples.apps.iosched.permission.WRITE_SCHEDULE" />
|
||||
|
||||
<service
|
||||
android:name=".sync.SyncService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/syncadapter" />
|
||||
</service>
|
||||
|
||||
<!-- An IntentService responsible for bootstrapping the app with the necessary
|
||||
data such as session, speakers, etc. This data is used prior to the app's
|
||||
first sync to the backend server. -->
|
||||
<service
|
||||
android:name=".services.DataBootstrapService"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package com.mozilla.hackathon.kiboko;
|
||||
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class Config {
|
||||
|
||||
// Warning messages for dogfood build
|
||||
public static final String DOGFOOD_BUILD_WARNING_TITLE = "DOGFOOD BUILD";
|
||||
|
||||
public static final String DOGFOOD_BUILD_WARNING_TEXT = "Shhh! This is a pre-release build "
|
||||
+ "of the I/O app. Don't show it around.";
|
||||
|
||||
public static final TimeZone TIMEZONE = TimeZone.getDefault();
|
||||
|
||||
|
||||
// YouTube share URL
|
||||
public static final String YOUTUBE_SHARE_URL_PREFIX = "http://youtu.be/";
|
||||
|
||||
// Play store URL prefix
|
||||
public static final String PLAY_STORE_URL_PREFIX
|
||||
= "https://play.google.com/store/apps/details?id=";
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.mozilla.hackathon.kiboko.io;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.Context;
|
||||
|
||||
import com.google.common.base.Charsets;
|
||||
import com.google.gson.JsonElement;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.io.StringWriter;
|
||||
import java.io.Writer;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public abstract class JSONHandler {
|
||||
|
||||
protected static Context mContext;
|
||||
|
||||
public JSONHandler(Context context) {
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
public abstract void makeContentProviderOperations(ArrayList<ContentProviderOperation> list);
|
||||
|
||||
public abstract void process(JsonElement element);
|
||||
|
||||
public static String parseResource(Context context, int resource) throws IOException {
|
||||
InputStream is = context.getResources().openRawResource(resource);
|
||||
Writer writer = new StringWriter();
|
||||
char[] buffer = new char[1024];
|
||||
try {
|
||||
Reader reader = new BufferedReader(new InputStreamReader(is, Charsets.UTF_8));
|
||||
int n;
|
||||
while ((n = reader.read(buffer)) != -1) {
|
||||
writer.write(buffer, 0, n);
|
||||
}
|
||||
} finally {
|
||||
is.close();
|
||||
}
|
||||
|
||||
return writer.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package com.mozilla.hackathon.kiboko.io;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.provider.BaseColumns;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.mozilla.hackathon.kiboko.models.Tutorial;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContract;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContractHelper;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoDatabase;
|
||||
import com.mozilla.hackathon.kiboko.utilities.TimeUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGW;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Created by Audrey on 25/06/2016.
|
||||
*/
|
||||
public class TutorialsHandler extends JSONHandler {
|
||||
private static final String TAG = makeLogTag(TutorialsHandler.class);
|
||||
private HashMap<String, Tutorial> mTutorials = new HashMap<String, Tutorial>();
|
||||
|
||||
|
||||
public TutorialsHandler(Context context) {
|
||||
super(context);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(JsonElement element) {
|
||||
for (Tutorial tutorial : new Gson().fromJson(element, Tutorial[].class)) {
|
||||
mTutorials.put(tutorial.id, tutorial);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void makeContentProviderOperations(ArrayList<ContentProviderOperation> list) {
|
||||
Uri uri = DsoContractHelper.setUriAsCalledFromSyncAdapter(
|
||||
DsoContract.Tutorials.CONTENT_URI);
|
||||
|
||||
// build a map of tutorial to tutorial import hashcode so we know what to update,
|
||||
// what to insert, and what to delete
|
||||
HashMap<String, String> tutorialHashCodes = loadtutorialHashCodes();
|
||||
boolean incrementalUpdate = (tutorialHashCodes != null) && (tutorialHashCodes.size() > 0);
|
||||
|
||||
// set of tutorials that we want to keep after the sync
|
||||
HashSet<String> tutorialsToKeep = new HashSet<String>();
|
||||
|
||||
if (incrementalUpdate) {
|
||||
LOGD(TAG, "Doing incremental update for tutorials.");
|
||||
} else {
|
||||
LOGD(TAG, "Doing full (non-incremental) update for tutorials.");
|
||||
list.add(ContentProviderOperation.newDelete(uri).build());
|
||||
}
|
||||
|
||||
int updatedtutorials = 0;
|
||||
for (Tutorial tutorial : mTutorials.values()) {
|
||||
// Set the tutorial grouping order in the object, so it can be used in hash calculation
|
||||
tutorial.groupingOrder = computeTypeOrder(tutorial);
|
||||
|
||||
// compute the incoming tutorial's hashcode to figure out if we need to update
|
||||
String hashCode = tutorial.getImportHashCode();
|
||||
tutorialsToKeep.add(tutorial.id);
|
||||
|
||||
// add tutorial, if necessary
|
||||
if (!incrementalUpdate || !tutorialHashCodes.containsKey(tutorial.id) ||
|
||||
!tutorialHashCodes.get(tutorial.id).equals(hashCode)) {
|
||||
++updatedtutorials;
|
||||
boolean isNew = !incrementalUpdate || !tutorialHashCodes.containsKey(tutorial.id);
|
||||
buildtutorial(isNew, tutorial, list);
|
||||
|
||||
// add relationships to speakers and track
|
||||
// buildtutorialSpeakerMapping(tutorial, list);
|
||||
// buildTagsMapping(tutorial, list);
|
||||
}
|
||||
}
|
||||
|
||||
int deletedtutorials = 0;
|
||||
if (incrementalUpdate) {
|
||||
for (String tutorialId : tutorialHashCodes.keySet()) {
|
||||
if (!tutorialsToKeep.contains(tutorialId)) {
|
||||
buildDeleteOperation(tutorialId, list);
|
||||
++deletedtutorials;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGD(TAG, "tutorials: " + (incrementalUpdate ? "INCREMENTAL" : "FULL") + " update. " +
|
||||
updatedtutorials + " to update, " + deletedtutorials + " to delete. New total: " +
|
||||
mTutorials.size());
|
||||
}
|
||||
|
||||
private void buildDeleteOperation(String tutorialId, List<ContentProviderOperation> list) {
|
||||
Uri tutorialUri = DsoContractHelper.setUriAsCalledFromSyncAdapter(
|
||||
DsoContract.Tutorials.buildTutorialUri(tutorialId));
|
||||
list.add(ContentProviderOperation.newDelete(tutorialUri).build());
|
||||
}
|
||||
|
||||
private HashMap<String, String> loadtutorialHashCodes() {
|
||||
Uri uri = DsoContractHelper.setUriAsCalledFromSyncAdapter(
|
||||
DsoContract.Tutorials.CONTENT_URI);
|
||||
LOGD(TAG, "Loading tutorial hashcodes for tutorial import optimization.");
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = mContext.getContentResolver().query(uri, tutorialHashcodeQuery.PROJECTION,
|
||||
null, null, null);
|
||||
if (cursor == null || cursor.getCount() < 1) {
|
||||
LOGW(TAG, "Warning: failed to load tutorial hashcodes. Not optimizing tutorial import.");
|
||||
return null;
|
||||
}
|
||||
HashMap<String, String> hashcodeMap = new HashMap<String, String>();
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
String tutorialId = cursor.getString(tutorialHashcodeQuery.Tutorial_ID);
|
||||
String hashcode = cursor.getString(tutorialHashcodeQuery.Tutorial_IMPORT_HASHCODE);
|
||||
hashcodeMap.put(tutorialId, hashcode == null ? "" : hashcode);
|
||||
} while (cursor.moveToNext());
|
||||
}
|
||||
LOGD(TAG, "tutorial hashcodes loaded for " + hashcodeMap.size() + " tutorials.");
|
||||
return hashcodeMap;
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder mStringBuilder = new StringBuilder();
|
||||
|
||||
private void buildtutorial(boolean isInsert,
|
||||
Tutorial tutorial, ArrayList<ContentProviderOperation> list) {
|
||||
ContentProviderOperation.Builder builder;
|
||||
Uri alltutorialsUri = DsoContractHelper
|
||||
.setUriAsCalledFromSyncAdapter(DsoContract.Tutorials.CONTENT_URI);
|
||||
Uri thistutorialUri = DsoContractHelper
|
||||
.setUriAsCalledFromSyncAdapter(DsoContract.Tutorials.buildtutorialUri(
|
||||
tutorial.id));
|
||||
|
||||
if (isInsert) {
|
||||
builder = ContentProviderOperation.newInsert(alltutorialsUri);
|
||||
} else {
|
||||
builder = ContentProviderOperation.newUpdate(thistutorialUri);
|
||||
}
|
||||
|
||||
// String speakerNames = "";
|
||||
// if (mSpeakerMap != null) {
|
||||
// // build human-readable list of speakers
|
||||
// mStringBuilder.setLength(0);
|
||||
// for (int i = 0; i < tutorial.speakers.length; ++i) {
|
||||
// if (mSpeakerMap.containsKey(tutorial.speakers[i])) {
|
||||
// mStringBuilder
|
||||
// .append(i == 0 ? "" : i == tutorial.speakers.length - 1 ? " and " : ", ")
|
||||
// .append(mSpeakerMap.get(tutorial.speakers[i]).name.trim());
|
||||
// } else {
|
||||
// LOGW(TAG, "Unknown speaker ID " + tutorial.speakers[i] + " in tutorial " + tutorial.id);
|
||||
// }
|
||||
// }
|
||||
// speakerNames = mStringBuilder.toString();
|
||||
// } else {
|
||||
// LOGE(TAG, "Can't build speaker names -- speaker map is null.");
|
||||
// }
|
||||
|
||||
// int color = mDefaulttutorialColor;
|
||||
// try {
|
||||
// if (!TextUtils.isEmpty(tutorial.color)) {
|
||||
// color = Color.parseColor(tutorial.color);
|
||||
// }
|
||||
// } catch (IllegalArgumentException ex) {
|
||||
// LOGD(TAG, "Ignoring invalid formatted tutorial color: "+tutorial.color);
|
||||
// }
|
||||
|
||||
builder.withValue(DsoContract.SyncColumns.UPDATED, System.currentTimeMillis())
|
||||
.withValue(DsoContract.Tutorials.Tutorial_ID, tutorial.id)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_LEVEL, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.Tutorial_TITLE, tutorial.title)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_ABSTRACT, tutorial.description)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_HASHTAG, tutorial.hashtag)
|
||||
|
||||
// Note: we store this comma-separated list of tags IN ADDITION
|
||||
// to storing the tags in proper relational format (in the tutorials_tags
|
||||
// relationship table). This is because when querying for tutorials,
|
||||
// we don't want to incur the performance penalty of having to do a
|
||||
// subquery for every record to figure out the list of tags of each tutorial.
|
||||
// Note: we store the human-readable list of speakers (which is redundant
|
||||
// with the tutorials_speakers relationship table) so that we can
|
||||
// display it easily in lists without having to make an additional DB query
|
||||
// (or another join) for each record.
|
||||
.withValue(DsoContract.Tutorials.Tutorial_KEYWORDS, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.Tutorial_URL, tutorial.url)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_LIVESTREAM_ID,
|
||||
tutorial.isLivestream ? tutorial.youtubeUrl : null)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_MODERATOR_URL, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.Tutorial_REQUIREMENTS, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.Tutorial_YOUTUBE_URL,
|
||||
tutorial.isLivestream ? null : tutorial.youtubeUrl)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_PDF_URL, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.Tutorial_NOTES_URL, null) // Not available
|
||||
.withValue(DsoContract.Tutorials.ROOM_ID, tutorial.room)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_GROUPING_ORDER, tutorial.groupingOrder)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_IMPORT_HASHCODE,
|
||||
tutorial.getImportHashCode())
|
||||
.withValue(DsoContract.Tutorials.Tutorial_MAIN_TAG, tutorial.mainTag)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_CAPTIONS_URL, tutorial.captionsUrl)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_PHOTO_URL, tutorial.photoUrl)
|
||||
// Disabled since this isn't being used by this app.
|
||||
// .withValue(DsoContract.Tutorials.Tutorial_RELATED_CONTENT, tutorial.relatedContent)
|
||||
.withValue(DsoContract.Tutorials.Tutorial_COLOR, color);
|
||||
list.add(builder.build());
|
||||
}
|
||||
|
||||
// The type order of a tutorial is the order# (in its category) of the tag that indicates
|
||||
// its type. So if we sort tutorials by type order, they will be neatly grouped by type,
|
||||
// with the types appearing in the order given by the tag category that represents the
|
||||
// concept of tutorial type.
|
||||
private int computeTypeOrder(Tutorial tutorial) {
|
||||
int order = Integer.MAX_VALUE;
|
||||
int keynoteOrder = -1;
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
private interface tutorialHashcodeQuery {
|
||||
String[] PROJECTION = {
|
||||
BaseColumns._ID,
|
||||
DsoContract.Tutorials.Tutorial_ID,
|
||||
DsoContract.Tutorials.Tutorial_IMPORT_HASHCODE
|
||||
};
|
||||
int _ID = 0;
|
||||
int tutorial_ID = 1;
|
||||
int tutorial_IMPORT_HASHCODE = 2;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.mozilla.hackathon.kiboko.models;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Created by Audrey on 25/06/2016.
|
||||
*/
|
||||
public class Tutorial {
|
||||
public String id;
|
||||
public String tag;
|
||||
public String header;
|
||||
public String photoUrl;
|
||||
public Step[] steps;
|
||||
public int groupingOrder;
|
||||
|
||||
public class Step {
|
||||
public String id;
|
||||
public String title;
|
||||
public String description;
|
||||
public String gifUrl;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Step{" +
|
||||
"id='" + id + '\'' +
|
||||
", title='" + title + '\'' +
|
||||
", description='" + description + '\'' +
|
||||
", gifUrl='" + gifUrl + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
public String getImportHashCode() {
|
||||
return (new Random()).nextLong() + "";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
import android.app.SearchManager;
|
||||
import android.net.Uri;
|
||||
import android.provider.BaseColumns;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Contract class for interacting with {@link DsoProvider}. Unless otherwise noted, all
|
||||
* time-based fields are milliseconds since epoch and can be compared against
|
||||
* {@link System#currentTimeMillis()}.
|
||||
* <p>
|
||||
* The backing {@link android.content.ContentProvider} assumes that {@link android.net.Uri}
|
||||
* are generated using stronger {@link java.lang.String} identifiers, instead of
|
||||
* {@code int} {@link android.provider.BaseColumns#_ID} values, which are prone to shuffle during
|
||||
* sync.
|
||||
*/
|
||||
public final class DsoContract {
|
||||
|
||||
public static final String CONTENT_TYPE_APP_BASE = "mozilladso2016.";
|
||||
|
||||
public static final String CONTENT_TYPE_BASE = "vnd.android.cursor.dir/vnd."
|
||||
+ CONTENT_TYPE_APP_BASE;
|
||||
|
||||
public static final String CONTENT_ITEM_TYPE_BASE = "vnd.android.cursor.item/vnd."
|
||||
+ CONTENT_TYPE_APP_BASE;
|
||||
|
||||
public interface SyncColumns {
|
||||
|
||||
/** Last time this entry was updated or synchronized. */
|
||||
String UPDATED = "updated";
|
||||
}
|
||||
|
||||
interface TutorialsColumns {
|
||||
|
||||
/** Unique string identifying this tutorial. */
|
||||
String TUTORIAL_ID = "tutorial_id";
|
||||
/** Tutorial header. */
|
||||
String TUTORIAL_HEADER = "tutorial_header";
|
||||
|
||||
String TUTORIAL_TAGS = "tutorial_tags";
|
||||
|
||||
String TUTORIAL_PHOTO_URL = "tutorial_photo_url";
|
||||
/** The Tutorials's steps. */
|
||||
String TUTORIAL_STEPS = "tutorial_steps";
|
||||
}
|
||||
|
||||
|
||||
public static final String CONTENT_AUTHORITY = "com.mozilla.hackathon.kiboko";
|
||||
|
||||
public static final Uri BASE_CONTENT_URI = Uri.parse("content://" + CONTENT_AUTHORITY);
|
||||
|
||||
|
||||
private static final String PATH_TAGS = "tags";
|
||||
|
||||
|
||||
private static final String PATH_TUTORIALS = "tutorials";
|
||||
|
||||
|
||||
private static final String PATH_SEARCH = "search";
|
||||
|
||||
private static final String PATH_SEARCH_SUGGEST = "search_suggest_query";
|
||||
|
||||
private static final String PATH_SEARCH_INDEX = "search_index";
|
||||
|
||||
|
||||
public static final String[] TOP_LEVEL_PATHS = {
|
||||
PATH_TAGS,
|
||||
PATH_TUTORIALS,
|
||||
};
|
||||
|
||||
public static String makeContentType(String id) {
|
||||
if (id != null) {
|
||||
return CONTENT_TYPE_BASE + id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String makeContentItemType(String id) {
|
||||
if (id != null) {
|
||||
return CONTENT_ITEM_TYPE_BASE + id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Each tutorial has zero or more
|
||||
*/
|
||||
public static class Tutorials implements TutorialsColumns,
|
||||
SyncColumns, BaseColumns {
|
||||
|
||||
public static final String QUERY_PARAMETER_TAG_FILTER = "filter";
|
||||
public static final String QUERY_PARAMETER_CATEGORIES = "categories";
|
||||
|
||||
public static final Uri CONTENT_URI =
|
||||
BASE_CONTENT_URI.buildUpon().appendPath(PATH_TUTORIALS).build();
|
||||
|
||||
public static final String CONTENT_TYPE_ID = "tutorial";
|
||||
|
||||
// ORDER BY clauses
|
||||
// public static final String SORT_BY_TYPE_THEN_TIME = TUTORIAL_GROUPING_ORDER + " ASC,"
|
||||
// + TUTORIAL_START + " ASC," + TUTORIAL_TITLE + " COLLATE NOCASE ASC";
|
||||
//
|
||||
// public static final String LIVESTREAM_SELECTION =
|
||||
// TUTORIAL_LIVESTREAM_ID + " is not null AND " + TUTORIAL_LIVESTREAM_ID + "!=''";
|
||||
//
|
||||
// public static final String LIVESTREAM_OR_YOUTUBE_URL_SELECTION = "(" +
|
||||
// TUTORIAL_LIVESTREAM_ID + " is not null AND " + TUTORIAL_LIVESTREAM_ID +
|
||||
// "!='') OR (" +
|
||||
// TUTORIAL_YOUTUBE_URL + " is not null AND " + TUTORIAL_YOUTUBE_URL + " != '')";
|
||||
|
||||
// Builds selectionArgs for {@link STARTING_AT_TIME_INTERVAL_SELECTION}
|
||||
public static String[] buildAtTimeIntervalArgs(long intervalStart, long intervalEnd) {
|
||||
return new String[]{String.valueOf(intervalStart), String.valueOf(intervalEnd)};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Build {@link Uri} for requested {@link #TUTORIAL_ID}. */
|
||||
public static Uri buildTutorialUri(String tutorialId) {
|
||||
return CONTENT_URI.buildUpon().appendPath(tutorialId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build {@link Uri} that references tutorials that match the query. The query can be
|
||||
* multiple words separated with spaces.
|
||||
*
|
||||
* @param query The query. Can be multiple words separated by spaces.
|
||||
* @return {@link Uri} to the tutorials
|
||||
*/
|
||||
public static Uri buildSearchUri(String query) {
|
||||
if (null == query) {
|
||||
query = "";
|
||||
}
|
||||
// convert "lorem ipsum dolor sit" to "lorem* ipsum* dolor* sit*"
|
||||
query = query.replaceAll(" +", " *") + "*";
|
||||
return CONTENT_URI.buildUpon()
|
||||
.appendPath(PATH_SEARCH).appendPath(query).build();
|
||||
}
|
||||
|
||||
public static boolean isSearchUri(Uri uri) {
|
||||
List<String> pathSegments = uri.getPathSegments();
|
||||
return pathSegments.size() >= 2 && PATH_SEARCH.equals(pathSegments.get(1));
|
||||
}
|
||||
|
||||
public static long[] getInterval(Uri uri) {
|
||||
if (uri == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> segments = uri.getPathSegments();
|
||||
if (segments.size() == 3 && segments.get(2).indexOf('-') > 0) {
|
||||
String[] interval = segments.get(2).split("-");
|
||||
return new long[]{Long.parseLong(interval[0]), Long.parseLong(interval[1])};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read {@link #TUTORIAL_ID} from {@link Tutorials} {@link Uri}. */
|
||||
public static String getTutorialId(Uri uri) {
|
||||
return uri.getPathSegments().get(1);
|
||||
}
|
||||
|
||||
public static String getSearchQuery(Uri uri) {
|
||||
List<String> segments = uri.getPathSegments();
|
||||
if (2 < segments.size()) {
|
||||
return segments.get(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean hasFilterParam(Uri uri) {
|
||||
return uri != null && uri.getQueryParameter(QUERY_PARAMETER_TAG_FILTER) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build {@link Uri} that references all tutorials that have ALL of the indicated tags.
|
||||
* @param contentUri The base Uri that is used for adding the required tags.
|
||||
* @param requiredTags The tags that are used for creating the query parameter.
|
||||
* @return uri The uri updated to include the indicated tags.
|
||||
*/
|
||||
@Deprecated
|
||||
public static Uri buildTagFilterUri(Uri contentUri, String[] requiredTags) {
|
||||
return buildCategoryTagFilterUri(contentUri, requiredTags,
|
||||
requiredTags == null ? 0 : requiredTags.length);
|
||||
}
|
||||
|
||||
/** Build {@link Uri} that references all tutorials that have ALL of the indicated tags. */
|
||||
@Deprecated
|
||||
public static Uri buildTagFilterUri(String[] requiredTags) {
|
||||
return buildTagFilterUri(CONTENT_URI, requiredTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build {@link Uri} that references all tutorials that have the following tags and
|
||||
* satisfy the requirement of containing ALL the categories
|
||||
* @param contentUri The base Uri that is used for adding the query parameters.
|
||||
* @param tags The various tags that can include topics, themes as well as types.
|
||||
* @param categories The number of categories that are required. At most this can be 3,
|
||||
* since a tutorial can belong only to one type + topic + theme.
|
||||
* @return Uri representing the query parameters for the filter as well as the categories.
|
||||
*/
|
||||
public static Uri buildCategoryTagFilterUri(Uri contentUri, String[] tags, int categories) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String tag : tags) {
|
||||
if (TextUtils.isEmpty(tag)) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append(tag.trim());
|
||||
}
|
||||
if (sb.length() == 0) {
|
||||
return contentUri;
|
||||
} else {
|
||||
return contentUri.buildUpon()
|
||||
.appendQueryParameter(QUERY_PARAMETER_TAG_FILTER, sb.toString())
|
||||
.appendQueryParameter(QUERY_PARAMETER_CATEGORIES,
|
||||
String.valueOf(categories))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class SearchSuggest {
|
||||
|
||||
public static final Uri CONTENT_URI =
|
||||
BASE_CONTENT_URI.buildUpon().appendPath(PATH_SEARCH_SUGGEST).build();
|
||||
|
||||
public static final String DEFAULT_SORT = SearchManager.SUGGEST_COLUMN_TEXT_1
|
||||
+ " COLLATE NOCASE ASC";
|
||||
}
|
||||
|
||||
public static class SearchIndex {
|
||||
|
||||
public static final Uri CONTENT_URI =
|
||||
BASE_CONTENT_URI.buildUpon().appendPath(PATH_SEARCH_INDEX).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns for an in memory table created on query using
|
||||
* the Tags table and the SearchTutorials table.
|
||||
*/
|
||||
public interface SearchTopicTutorialsColumns extends BaseColumns {
|
||||
/* This column contains either a tag_id or a tutorial_id */
|
||||
String TAG_OR_TUTORIAL_ID = "tag_or_tutorial_id";
|
||||
/* This column contains the search snippet to be shown to the user.*/
|
||||
String SEARCH_SNIPPET = "search_snippet";
|
||||
/* Indicates whether this row is a topic tag or a tutorial_id. */
|
||||
String IS_TOPIC_TAG = "is_topic_tag";
|
||||
}
|
||||
|
||||
private DsoContract() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
/**
|
||||
* Provides helper methods for specifying query parameters on {@code Uri}s.
|
||||
*/
|
||||
public class DsoContractHelper {
|
||||
|
||||
public static final String QUERY_PARAMETER_DISTINCT = "distinct";
|
||||
|
||||
private static final String QUERY_PARAMETER_CALLER_IS_SYNC_ADAPTER = "callerIsSyncAdapter";
|
||||
|
||||
|
||||
public static boolean isUriCalledFromSyncAdapter(Uri uri) {
|
||||
return uri.getBooleanQueryParameter(QUERY_PARAMETER_CALLER_IS_SYNC_ADAPTER, false);
|
||||
}
|
||||
|
||||
public static Uri setUriAsCalledFromSyncAdapter(Uri uri) {
|
||||
return uri.buildUpon().appendQueryParameter(QUERY_PARAMETER_CALLER_IS_SYNC_ADAPTER, "true")
|
||||
.build();
|
||||
}
|
||||
|
||||
public static boolean isQueryDistinct(Uri uri){
|
||||
return !TextUtils.isEmpty(uri.getQueryParameter(QUERY_PARAMETER_DISTINCT));
|
||||
}
|
||||
|
||||
public static String formatQueryDistinctParameter(String parameter){
|
||||
return DsoContractHelper.QUERY_PARAMETER_DISTINCT + " " + parameter;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.SearchManager;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
import android.provider.BaseColumns;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.services.SyncHelper;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContract.*;
|
||||
import com.mozilla.hackathon.kiboko.sync.DsoDataHandler;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGW;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Created by Audrey on 25/06/2016.
|
||||
*/
|
||||
public class DsoDatabase extends SQLiteOpenHelper {
|
||||
private static final String TAG = makeLogTag(DsoDatabase.class);
|
||||
|
||||
private static final String DATABASE_NAME = "mozilladso.db";
|
||||
|
||||
// NOTE: carefully update onUpgrade() when bumping database versions to make
|
||||
// sure user data is saved.
|
||||
|
||||
private static final int VER_2015_RELEASE_A = 208;
|
||||
private static final int VER_2015_RELEASE_B = 210;
|
||||
private static final int CUR_DATABASE_VERSION = VER_2015_RELEASE_B;
|
||||
|
||||
private final Context mContext;
|
||||
|
||||
interface Tables {
|
||||
String TUTORIALS = "tutorials";
|
||||
String MY_SCHEDULE = "myschedule";
|
||||
|
||||
// String TUTORIALS_JOIN_MYSCHEDULE = "tutorials "
|
||||
// + "LEFT OUTER JOIN myschedule ON tutorials.session_id=myschedule.session_id "
|
||||
// + "AND myschedule.account_name=? ";
|
||||
|
||||
String TUTORIALS_SEARCH_JOIN_TUTORIALS_ROOMS = "tutorials_search "
|
||||
+ "LEFT OUTER JOIN tutorials ON tutorials_search.session_id=tutorials.session_id "
|
||||
+ "LEFT OUTER JOIN myschedule ON tutorials.session_id=myschedule.session_id "
|
||||
+ "AND myschedule.account_name=? "
|
||||
+ "LEFT OUTER JOIN rooms ON tutorials.room_id=rooms.room_id";
|
||||
|
||||
// When tables get deprecated, add them to this list (so they get correctly deleted
|
||||
// on database upgrades).
|
||||
enum DeprecatedTables {
|
||||
SANDBOX("sandbox");
|
||||
|
||||
String tableName;
|
||||
|
||||
DeprecatedTables(String tableName) {
|
||||
this.tableName = tableName;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private interface Triggers {
|
||||
// Deletes from dependent tables when corresponding tutorials are deleted.
|
||||
String TUTORIALS_TAGS_DELETE = "tutorials_tags_delete";
|
||||
|
||||
// When triggers get deprecated, add them to this list (so they get correctly deleted
|
||||
// on database upgrades).
|
||||
interface DeprecatedTriggers {
|
||||
String TUTORIALS_TRACKS_DELETE = "tutorials_tracks_delete";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public interface TutorialsSteps {
|
||||
String TUTORIAL_ID = "tutorial_id";
|
||||
String STEP_ID = "step_id";
|
||||
}
|
||||
|
||||
interface TutorialsSearchColumns {
|
||||
String TUTORIAL_ID = "tutorial_id";
|
||||
String BODY = "body";
|
||||
}
|
||||
|
||||
/** {@code REFERENCES} clauses. */
|
||||
private interface References {
|
||||
String TUTORIAL_ID = "REFERENCES " + Tables.TUTORIALS + "(" + Tutorials.TUTORIAL_ID + ")";
|
||||
}
|
||||
|
||||
public DsoDatabase(Context context) {
|
||||
super(context, DATABASE_NAME, null, CUR_DATABASE_VERSION);
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(SQLiteDatabase db) {
|
||||
|
||||
|
||||
db.execSQL("CREATE TABLE " + Tables.TUTORIALS + " ("
|
||||
+ BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ DsoContract.SyncColumns.UPDATED + " INTEGER NOT NULL,"
|
||||
+ TutorialsColumns.TUTORIAL_ID + " TEXT NOT NULL,"
|
||||
+ TutorialsColumns.TUTORIAL_HEADER + " TEXT,"
|
||||
+ TutorialsColumns.TUTORIAL_PHOTO_URL + " TEXT,"
|
||||
+ TutorialsColumns.TUTORIAL_STEPS + " TEXT,"
|
||||
+ "UNIQUE (" + TutorialsColumns.TUTORIAL_ID + ") ON CONFLICT REPLACE)");
|
||||
|
||||
upgradeFrom2015Ato2015B(db);
|
||||
}
|
||||
|
||||
private void upgradeFrom2015Ato2015B(SQLiteDatabase db) {
|
||||
// Note: SpeakersColumns.SPEAKER_URL is unused in 2015. The columns added here are used
|
||||
// instead.
|
||||
// db.execSQL("ALTER TABLE " + Tables.SPEAKERS
|
||||
// + " ADD COLUMN " + SpeakersColumns.SPEAKER_PLUSONE_URL + " TEXT");
|
||||
// db.execSQL("ALTER TABLE " + Tables.SPEAKERS
|
||||
// + " ADD COLUMN " + SpeakersColumns.SPEAKER_TWITTER_URL + " TEXT");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
LOGD(TAG, "onUpgrade() from " + oldVersion + " to " + newVersion);
|
||||
|
||||
// Current DB version. We update this variable as we perform upgrades to reflect
|
||||
// the current version we are in.
|
||||
int version = oldVersion;
|
||||
|
||||
// Indicates whether the data we currently have should be invalidated as a
|
||||
// result of the db upgrade. Default is true (invalidate); if we detect that this
|
||||
// is a trivial DB upgrade, we set this to false.
|
||||
boolean dataInvalidated = true;
|
||||
|
||||
// Check if we can upgrade from release 2015 A to release 2015 B.
|
||||
if (version == VER_2015_RELEASE_A) {
|
||||
LOGD(TAG, "Upgrading database from 2015 release A to 2015 release B.");
|
||||
upgradeFrom2015Ato2015B(db);
|
||||
version = VER_2015_RELEASE_B;
|
||||
}
|
||||
|
||||
LOGD(TAG, "After upgrade logic, at version " + version);
|
||||
|
||||
// Drop tables that have been deprecated.
|
||||
for (Tables.DeprecatedTables deprecatedTable : Tables.DeprecatedTables.values()) {
|
||||
db.execSQL(("DROP TABLE IF EXISTS " + deprecatedTable.tableName));
|
||||
}
|
||||
|
||||
// At this point, we ran out of upgrade logic, so if we are still at the wrong
|
||||
// version, we have no choice but to delete everything and create everything again.
|
||||
if (version != CUR_DATABASE_VERSION) {
|
||||
LOGW(TAG, "Upgrade unsuccessful -- destroying old data during upgrade");
|
||||
db.execSQL("DROP TRIGGER IF EXISTS " + Triggers.TUTORIALS_TAGS_DELETE);
|
||||
db.execSQL("DROP TABLE IF EXISTS " + Tables.TUTORIALS);
|
||||
|
||||
onCreate(db);
|
||||
version = CUR_DATABASE_VERSION;
|
||||
}
|
||||
|
||||
if (dataInvalidated) {
|
||||
LOGD(TAG, "Data invalidated; resetting our data timestamp.");
|
||||
DsoDataHandler.resetDataTimestamp(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteDatabase(Context context) {
|
||||
context.deleteDatabase(DATABASE_NAME);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
import android.app.SearchManager;
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentProviderResult;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.BaseColumns;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.common.collect.Tables;
|
||||
import com.mozilla.hackathon.kiboko.Config;
|
||||
import com.mozilla.hackathon.kiboko.settings.SettingsUtils;
|
||||
import com.mozilla.hackathon.kiboko.utilities.SelectionBuilder;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGV;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Created by Audrey on 25/06/2016.
|
||||
*/
|
||||
public class DsoProvider extends ContentProvider {
|
||||
|
||||
private static final String TAG = makeLogTag(DsoProvider.class);
|
||||
|
||||
private DsoDatabase mOpenHelper;
|
||||
|
||||
private DsoProviderUriMatcher mUriMatcher;
|
||||
|
||||
/**
|
||||
* Providing important state information to be included in bug reports.
|
||||
*
|
||||
* !!! Remember !!! Any important data logged to {@code writer} shouldn't contain personally
|
||||
* identifiable information as it can be seen in bugreports.
|
||||
*/
|
||||
@Override
|
||||
public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
|
||||
Context context = getContext();
|
||||
|
||||
// Using try/catch block in case there are issues retrieving information to log.
|
||||
try {
|
||||
// Calling append in multiple calls is typically better than creating net new strings to
|
||||
// pass to method invocations.
|
||||
// writer.print("Last sync attempted: ");
|
||||
// writer.println(new java.util.Date(SettingsUtils.getLastSyncAttemptedTime(context)));
|
||||
// writer.print("Last sync successful: ");
|
||||
// writer.println(new java.util.Date(SettingsUtils.getLastSyncSucceededTime(context)));
|
||||
// writer.print("Current sync interval: ");
|
||||
// writer.println(SettingsUtils.getCurSyncInterval(context));
|
||||
|
||||
|
||||
} catch (Exception exception) {
|
||||
writer.append("Exception while dumping state: ");
|
||||
exception.printStackTrace(writer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
mOpenHelper = new DsoDatabase(getContext());
|
||||
mUriMatcher = new DsoProviderUriMatcher();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void deleteDatabase() {
|
||||
// TODO: wait for content provider operations to finish, then tear down
|
||||
mOpenHelper.close();
|
||||
Context context = getContext();
|
||||
DsoDatabase.deleteDatabase(context);
|
||||
mOpenHelper = new DsoDatabase(getContext());
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
return matchingUriEnum.contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a tuple of question marks. For example, if {@code count} is 3, returns "(?,?,?)".
|
||||
*/
|
||||
private String makeQuestionMarkTuple(int count) {
|
||||
if (count < 1) {
|
||||
return "()";
|
||||
}
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
stringBuilder.append("(?");
|
||||
for (int i = 1; i < count; i++) {
|
||||
stringBuilder.append(",?");
|
||||
}
|
||||
stringBuilder.append(")");
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
|
||||
String sortOrder) {
|
||||
final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
|
||||
|
||||
String tagsFilter = uri.getQueryParameter(DsoContract.Tutorials.QUERY_PARAMETER_TAG_FILTER);
|
||||
String categories = uri.getQueryParameter(DsoContract.Tutorials.QUERY_PARAMETER_CATEGORIES);
|
||||
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
|
||||
// Avoid the expensive string concatenation below if not loggable.
|
||||
if (Log.isLoggable(TAG, Log.VERBOSE)) {
|
||||
Log.v(TAG, "uri=" + uri + " code=" + matchingUriEnum.code + " proj=" +
|
||||
Arrays.toString(projection) + " selection=" + selection + " args="
|
||||
+ Arrays.toString(selectionArgs) + ")");
|
||||
}
|
||||
|
||||
switch (matchingUriEnum) {
|
||||
default: {
|
||||
// Most cases are handled with simple SelectionBuilder.
|
||||
final SelectionBuilder builder = buildExpandedSelection(uri, matchingUriEnum.code);
|
||||
|
||||
|
||||
boolean distinct = DsoContractHelper.isQueryDistinct(uri);
|
||||
|
||||
Cursor cursor = builder
|
||||
.where(selection, selectionArgs)
|
||||
.query(db, distinct, projection, sortOrder, null);
|
||||
|
||||
Context context = getContext();
|
||||
if (null != context) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), uri);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
|
||||
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
if (matchingUriEnum.table != null) {
|
||||
db.insertOrThrow(matchingUriEnum.table, null, values);
|
||||
notifyChange(uri);
|
||||
}
|
||||
|
||||
switch (matchingUriEnum) {
|
||||
case TUTORIALS: {
|
||||
return DsoContract.Tutorials.buildTutorialUri(values.getAsString(DsoContract.Tutorials.TUTORIAL_ID));
|
||||
}
|
||||
default: {
|
||||
throw new UnsupportedOperationException("Unknown insert uri: " + uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
|
||||
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
|
||||
int retVal = builder.where(selection, selectionArgs).update(db, values);
|
||||
notifyChange(uri);
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
|
||||
if (uri == DsoContract.BASE_CONTENT_URI) {
|
||||
// Handle whole database deletes (e.g. when signing out)
|
||||
deleteDatabase();
|
||||
notifyChange(uri);
|
||||
return 1;
|
||||
}
|
||||
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
|
||||
final SelectionBuilder builder = buildSimpleSelection(uri);
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
|
||||
|
||||
int retVal = builder.where(selection, selectionArgs).delete(db);
|
||||
notifyChange(uri);
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the system that the given {@code uri} data has changed.
|
||||
* <p/>
|
||||
* We only notify changes if the uri wasn't called by the sync adapter, to avoid issuing a large
|
||||
* amount of notifications while doing a sync. The
|
||||
* {@link com.google.samples.apps.iosched.sync.ConferenceDataHandler} notifies all top level
|
||||
* conference paths once the conference data sync is done, and the
|
||||
* {@link com.google.samples.apps.iosched.sync.userdata.AbstractUserDataSyncHelper} notifies all
|
||||
* user data related paths once the user data sync is done.
|
||||
*/
|
||||
private void notifyChange(Uri uri) {
|
||||
if (!DsoContractHelper.isUriCalledFromSyncAdapter(uri)) {
|
||||
Context context = getContext();
|
||||
context.getContentResolver().notifyChange(uri, null);
|
||||
|
||||
// Widgets can't register content observers so we refresh widgets separately.
|
||||
// context.sendBroadcast(ScheduleWidgetProvider.getRefreshBroadcastIntent(context, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given set of {@link ContentProviderOperation}, executing inside
|
||||
* a {@link SQLiteDatabase} transaction. All changes will be rolled back if
|
||||
* any single one fails.
|
||||
*/
|
||||
@Override
|
||||
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
|
||||
throws OperationApplicationException {
|
||||
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
try {
|
||||
final int numOperations = operations.size();
|
||||
final ContentProviderResult[] results = new ContentProviderResult[numOperations];
|
||||
for (int i = 0; i < numOperations; i++) {
|
||||
results[i] = operations.get(i).apply(this, results, i);
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
return results;
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a simple {@link SelectionBuilder} to match the requested
|
||||
* {@link Uri}. This is usually enough to support {@link #insert},
|
||||
* {@link #update}, and {@link #delete} operations.
|
||||
*/
|
||||
private SelectionBuilder buildSimpleSelection(Uri uri) {
|
||||
final SelectionBuilder builder = new SelectionBuilder();
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchUri(uri);
|
||||
// The main Uris, corresponding to the root of each type of Uri, do not have any selection
|
||||
// criteria so the full table is used. The others apply a selection criteria.
|
||||
switch (matchingUriEnum) {
|
||||
case TUTORIALS:
|
||||
case TUTORIALS_ID: {
|
||||
final String tutorialId = DsoContract.Tutorials.getTutorialId(uri);
|
||||
return builder.table(Tables.TUTORIALS)
|
||||
.where(TUTORIALS.TUTORIAL_ID + "=?", tutorialId);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an advanced {@link SelectionBuilder} to match the requested
|
||||
* {@link Uri}. This is usually only used by {@link #query}, since it
|
||||
* performs table joins useful for {@link Cursor} data.
|
||||
*/
|
||||
private SelectionBuilder buildExpandedSelection(Uri uri, int match) {
|
||||
final SelectionBuilder builder = new SelectionBuilder();
|
||||
DsoUriEnum matchingUriEnum = mUriMatcher.matchCode(match);
|
||||
if (matchingUriEnum == null) {
|
||||
throw new UnsupportedOperationException("Unknown uri: " + uri);
|
||||
}
|
||||
switch (matchingUriEnum) {
|
||||
case TUTORIALS: {
|
||||
// We query sessions on the joined table of sessions with rooms and tags.
|
||||
// Since there may be more than one tag per session, we GROUP BY session ID.
|
||||
// The starred sessions ("my schedule") are associated with a user, so we
|
||||
// use the current user to select them properly
|
||||
return builder
|
||||
.table(Tables.SESSIONS_JOIN_ROOMS_TAGS, getCurrentAccountName(uri, true))
|
||||
.mapToTable(DsoContract.Tutorials._ID, Tables.TUTORIALS)
|
||||
.mapToTable(DsoContract.Tutorials.TUTORIAL_ID, Tables.TUTORIALS)
|
||||
.map(DsoContract.Tutorials.SESSION_IN_MY_SCHEDULE, "IFNULL(in_schedule, 0)")
|
||||
.groupBy(Qualified.TUTORIALS_TUTORIAL_ID);
|
||||
}
|
||||
default: {
|
||||
throw new UnsupportedOperationException("Unknown uri: " + uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
throw new UnsupportedOperationException("openFile is not supported for " + uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link DsoContract} fields that are fully qualified with a specific
|
||||
* parent {@link Tables}. Used when needed to work around SQL ambiguity.
|
||||
*/
|
||||
private interface Qualified {
|
||||
String TUTORIALS_TUTORIAL_ID = Tables.TUTORIALS + "." + DsoContract.Tutorials.TUTORIAL_ID;
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
import android.content.UriMatcher;
|
||||
import android.net.Uri;
|
||||
import android.util.SparseArray;
|
||||
|
||||
/**
|
||||
* Created by Audrey on 26/06/2016.
|
||||
*/
|
||||
public class DsoProviderUriMatcher {
|
||||
|
||||
/**
|
||||
* All methods on a {@link UriMatcher} are thread safe, except {@code addURI}.
|
||||
*/
|
||||
private UriMatcher mUriMatcher;
|
||||
|
||||
private SparseArray<DsoUriEnum> mEnumsMap = new SparseArray<>();
|
||||
|
||||
/**
|
||||
* This constructor needs to be called from a thread-safe method as it isn't thread-safe itself.
|
||||
*/
|
||||
public DsoProviderUriMatcher(){
|
||||
mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
buildUriMatcher();
|
||||
}
|
||||
|
||||
private void buildUriMatcher() {
|
||||
final String authority = DsoContract.CONTENT_AUTHORITY;
|
||||
|
||||
DsoUriEnum[] uris = DsoUriEnum.values();
|
||||
for (int i = 0; i < uris.length; i++) {
|
||||
mUriMatcher.addURI(authority, uris[i].path, uris[i].code);
|
||||
}
|
||||
|
||||
buildEnumsMap();
|
||||
}
|
||||
|
||||
private void buildEnumsMap() {
|
||||
DsoUriEnum[] uris = DsoUriEnum.values();
|
||||
for (int i = 0; i < uris.length; i++) {
|
||||
mEnumsMap.put(uris[i].code, uris[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a {@code uri} to a {@link DsoUriEnum}.
|
||||
*
|
||||
* @return the {@link DsoUriEnum}, or throws new UnsupportedOperationException if no match.
|
||||
*/
|
||||
public DsoUriEnum matchUri(Uri uri){
|
||||
final int code = mUriMatcher.match(uri);
|
||||
try {
|
||||
return matchCode(code);
|
||||
} catch (UnsupportedOperationException e){
|
||||
throw new UnsupportedOperationException("Unknown uri " + uri);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a {@code code} to a {@link DsoUriEnum}.
|
||||
*
|
||||
* @return the {@link DsoUriEnum}, or throws new UnsupportedOperationException if no match.
|
||||
*/
|
||||
public DsoUriEnum matchCode(int code){
|
||||
DsoUriEnum dsoUriEnum = mEnumsMap.get(code);
|
||||
if (dsoUriEnum != null){
|
||||
return dsoUriEnum;
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Unknown uri with code " + code);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.mozilla.hackathon.kiboko.provider;
|
||||
|
||||
/**
|
||||
* The list of {@code Uri}s recognised by the {@code ContentProvider} of the app.
|
||||
* <p />
|
||||
* It is important to order them in the order that follows {@link android.content.UriMatcher}
|
||||
* matching rules: wildcard {@code *} applies to one segment only and it processes matching per
|
||||
* segment in a tree manner over the list of {@code Uri} in the order they are added.
|
||||
*/
|
||||
public enum DsoUriEnum {
|
||||
TUTORIALS(400, "tutorials", DsoContract.Tutorials.CONTENT_TYPE_ID, false, DsoDatabase.Tables.TUTORIALS),
|
||||
TUTORIALS_SEARCH(403, "tutorials/search/*", DsoContract.Tutorials.CONTENT_TYPE_ID, false, null),
|
||||
TUTORIALS_ID(405, "tutorials/*", DsoContract.Tutorials.CONTENT_TYPE_ID, true, null);
|
||||
public int code;
|
||||
|
||||
/**
|
||||
* The path to the {@link android.content.UriMatcher} will use to match. * may be used as a
|
||||
* wild card for any text, and # may be used as a wild card for numbers.
|
||||
*/
|
||||
public String path;
|
||||
|
||||
public String contentType;
|
||||
|
||||
public String table;
|
||||
|
||||
DsoUriEnum(int code, String path, String contentTypeId, boolean item, String table) {
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
this.contentType = item ? DsoContract.makeContentItemType(contentTypeId)
|
||||
: DsoContract.makeContentType(contentTypeId);
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
package com.mozilla.hackathon.kiboko.services;
|
||||
|
||||
import android.app.IntentService;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.BuildConfig;
|
||||
import com.mozilla.hackathon.kiboko.R;
|
||||
import com.mozilla.hackathon.kiboko.io.JSONHandler;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContract;
|
||||
import com.mozilla.hackathon.kiboko.settings.SettingsUtils;
|
||||
import com.mozilla.hackathon.kiboko.sync.DsoDataHandler;
|
||||
import com.mozilla.hackathon.kiboko.utilities.LogUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGE;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGI;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGW;
|
||||
|
||||
/**
|
||||
* An {@code IntentService} that performs the one-time data bootstrap. It takes the prepackaged
|
||||
* conference data from the R.raw.bootstrap_data resource, and populates the database. This data
|
||||
* contains the sessions, speakers, etc.
|
||||
*/
|
||||
public class DataBootstrapService extends IntentService {
|
||||
|
||||
private static final String TAG = LogUtils.makeLogTag(DataBootstrapService.class);
|
||||
|
||||
/**
|
||||
* Start the {@link DataBootstrapService} if the bootstrap is either not done or complete yet.
|
||||
*
|
||||
* @param context The context for starting the {@link IntentService} as well as checking if the
|
||||
* shared preference to mark the process as done is set.
|
||||
*/
|
||||
public static void startDataBootstrapIfNecessary(Context context) {
|
||||
if (!SettingsUtils.isDataBootstrapDone(context)) {
|
||||
LOGW(TAG, "One-time data bootstrap not done yet. Doing now.");
|
||||
context.startService(new Intent(context, DataBootstrapService.class));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DataBootstrapService.
|
||||
*/
|
||||
public DataBootstrapService() {
|
||||
super(TAG);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHandleIntent(Intent intent) {
|
||||
Context appContext = getApplicationContext();
|
||||
|
||||
if (SettingsUtils.isDataBootstrapDone(appContext)) {
|
||||
LOGD(TAG, "Data bootstrap already done.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LOGD(TAG, "Starting data bootstrap process.");
|
||||
// Load data from bootstrap raw resource.
|
||||
String bootstrapJson = JSONHandler
|
||||
.parseResource(appContext, R.raw.bootstrap_data);
|
||||
|
||||
// Apply the data we read to the database with the help of the ConferenceDataHandler.
|
||||
DsoDataHandler dataHandler = new DsoDataHandler(appContext);
|
||||
dataHandler.applyConferenceData(new String[]{bootstrapJson},
|
||||
BuildConfig.BOOTSTRAP_DATA_TIMESTAMP, false);
|
||||
|
||||
SyncHelper.performPostSyncChores(appContext);
|
||||
|
||||
LOGI(TAG, "End of bootstrap -- successful. Marking bootstrap as done.");
|
||||
// SettingsUtils.markSyncSucceededNow(appContext);
|
||||
SettingsUtils.markDataBootstrapDone(appContext);
|
||||
//
|
||||
getContentResolver().notifyChange(Uri.parse(DsoContract.CONTENT_AUTHORITY),
|
||||
null, false);
|
||||
|
||||
} catch (IOException ex) {
|
||||
// This is serious -- if this happens, the app won't work :-(
|
||||
// This is unlikely to happen in production, but IF it does, we apply
|
||||
// this workaround as a fallback: we pretend we managed to do the bootstrap
|
||||
// and hope that a remote sync will work.
|
||||
LOGE(TAG, "*** ERROR DURING BOOTSTRAP! Problem in bootstrap data?", ex);
|
||||
LOGE(TAG,
|
||||
"Applying fallback -- marking boostrap as done; sync might fix problem.");
|
||||
SettingsUtils.markDataBootstrapDone(appContext);
|
||||
} finally {
|
||||
// Request a manual sync immediately after the bootstrapping process, in case we
|
||||
// have an active connection. Otherwise, the scheduled sync could take a while.
|
||||
// SyncHelper.requestManualSync(AccountUtils.getActiveAccount(appContext));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.mozilla.hackathon.kiboko.services;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContract;
|
||||
import com.mozilla.hackathon.kiboko.settings.SettingsUtils;
|
||||
import com.mozilla.hackathon.kiboko.sync.SyncAdapter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* A helper class for dealing with conference data synchronization. All operations occur on the
|
||||
* thread they're called from, so it's best to wrap calls in an {@link android.os.AsyncTask}, or
|
||||
* better yet, a {@link android.app.Service}.
|
||||
*/
|
||||
public class SyncHelper {
|
||||
|
||||
private static final String TAG = makeLogTag(SyncHelper.class);
|
||||
|
||||
private Context mContext;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param context Can be Application, Activity or Service context.
|
||||
*/
|
||||
public SyncHelper(Context context) {
|
||||
mContext = context;
|
||||
|
||||
}
|
||||
|
||||
public static void requestManualSync(Account mChosenAccount) {
|
||||
requestManualSync(mChosenAccount, false);
|
||||
}
|
||||
|
||||
public static void requestManualSync(Account mChosenAccount, boolean userDataSyncOnly) {
|
||||
if (mChosenAccount != null) {
|
||||
LOGD(TAG, "Requesting manual sync for account " + mChosenAccount.name
|
||||
+ " userDataSyncOnly=" + userDataSyncOnly);
|
||||
Bundle b = new Bundle();
|
||||
b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
|
||||
b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
|
||||
if (userDataSyncOnly) {
|
||||
b.putBoolean(SyncAdapter.EXTRA_SYNC_DATA_ONLY, true);
|
||||
}
|
||||
ContentResolver
|
||||
.setSyncAutomatically(mChosenAccount, DsoContract.CONTENT_AUTHORITY, true);
|
||||
ContentResolver.setIsSyncable(mChosenAccount, DsoContract.CONTENT_AUTHORITY, 1);
|
||||
|
||||
boolean pending = ContentResolver.isSyncPending(mChosenAccount,
|
||||
DsoContract.CONTENT_AUTHORITY);
|
||||
if (pending) {
|
||||
LOGD(TAG, "Warning: sync is PENDING. Will cancel.");
|
||||
}
|
||||
boolean active = ContentResolver.isSyncActive(mChosenAccount,
|
||||
DsoContract.CONTENT_AUTHORITY);
|
||||
if (active) {
|
||||
LOGD(TAG, "Warning: sync is ACTIVE. Will cancel.");
|
||||
}
|
||||
|
||||
if (pending || active) {
|
||||
LOGD(TAG, "Cancelling previously pending/active sync.");
|
||||
ContentResolver.cancelSync(mChosenAccount, DsoContract.CONTENT_AUTHORITY);
|
||||
}
|
||||
|
||||
LOGD(TAG, "Requesting sync now.");
|
||||
ContentResolver.requestSync(mChosenAccount, DsoContract.CONTENT_AUTHORITY, b);
|
||||
} else {
|
||||
LOGD(TAG, "Can't request manual sync -- no chosen account.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to perform data synchronization. There are 3 types of data: conference, user
|
||||
* schedule and user feedback.
|
||||
* <p />
|
||||
* The conference data sync is handled by {@link RemoteConferenceDataFetcher}. For more details
|
||||
* about conference data, refer to the documentation at
|
||||
* https://github.com/google/iosched/blob/master/doc/SYNC.md. The user schedule data sync is
|
||||
* handled by {@link AbstractUserDataSyncHelper}. The user feedback sync is handled by
|
||||
* {@link FeedbackSyncHelper}.
|
||||
*
|
||||
*
|
||||
* @param syncResult The sync result object to update with statistics.
|
||||
* @param account The account associated with this sync
|
||||
* @param extras Specifies additional information about the sync. This must contain key
|
||||
* {@code SyncAdapter.EXTRA_SYNC_USER_DATA_ONLY} with boolean value
|
||||
* @return true if the sync changed the data.
|
||||
*/
|
||||
public boolean performSync(@Nullable SyncResult syncResult, Account account, Bundle extras) {
|
||||
boolean dataChanged = false;
|
||||
|
||||
if (!SettingsUtils.isDataBootstrapDone(mContext)) {
|
||||
LOGD(TAG, "Sync aborting (data bootstrap not done yet)");
|
||||
// Start the bootstrap process so that the next time sync is called,
|
||||
// it is already bootstrapped.
|
||||
DataBootstrapService.startDataBootstrapIfNecessary(mContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return dataChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the remote server has new conference data that we need to import. If so, download
|
||||
* the new data and import it into the database.
|
||||
*
|
||||
* @return Whether or not data was changed.
|
||||
* @throws IOException if there is a problem downloading or importing the data.
|
||||
*/
|
||||
private boolean doConferenceDataSync() throws IOException {
|
||||
if (!isOnline()) {
|
||||
LOGD(TAG, "Not attempting remote sync because device is OFFLINE");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOGD(TAG, "Starting remote sync.");
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
public static void performPostSyncChores(final Context context) {
|
||||
// Update search index.
|
||||
LOGD(TAG, "Updating search index.");
|
||||
context.getContentResolver().update(DsoContract.SearchIndex.CONTENT_URI,
|
||||
new ContentValues(), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are changes on User's Data to sync with/from remote AppData folder.
|
||||
*
|
||||
* @return Whether or not data was changed.
|
||||
* @throws IOException if there is a problem uploading the data.
|
||||
*/
|
||||
private boolean doUserDataSync(String accountName) throws IOException {
|
||||
if (!isOnline()) {
|
||||
LOGD(TAG, "Not attempting userdata sync because device is OFFLINE");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOGD(TAG, "Starting user data sync.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isOnline() {
|
||||
ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE);
|
||||
return cm.getActiveNetworkInfo() != null &&
|
||||
cm.getActiveNetworkInfo().isConnectedOrConnecting();
|
||||
}
|
||||
|
||||
private void increaseIoExceptions(SyncResult syncResult) {
|
||||
if (syncResult != null && syncResult.stats != null) {
|
||||
++syncResult.stats.numIoExceptions;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthException extends RuntimeException {
|
||||
|
||||
}
|
||||
|
||||
private static long calculateRecommendedSyncInterval(final Context context) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static void updateSyncInterval(final Context context, final Account account) {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package com.mozilla.hackathon.kiboko.services;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.sync.SyncAdapter;
|
||||
|
||||
/**
|
||||
* Service that handles sync. It simply instantiates a SyncAdapter and returns its IBinder.
|
||||
*/
|
||||
public class SyncService extends Service {
|
||||
private static final Object sSyncAdapterLock = new Object();
|
||||
private static SyncAdapter sSyncAdapter = null;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
synchronized (sSyncAdapterLock) {
|
||||
if (sSyncAdapter == null) {
|
||||
sSyncAdapter = new SyncAdapter(getApplicationContext(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return sSyncAdapter.getSyncAdapterBinder();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package com.mozilla.hackathon.kiboko.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Utilities and constants related to app settings_prefs.
|
||||
*/
|
||||
public class SettingsUtils {
|
||||
|
||||
private static final String TAG = makeLogTag(SettingsUtils.class);
|
||||
|
||||
/**
|
||||
* Boolean preference indicating whether the app has
|
||||
* {@code com.google.samples.apps.iosched.ui.BaseActivity.performDataBootstrap installed} the
|
||||
* {@code R.raw.bootstrap_data bootstrap data}.
|
||||
*/
|
||||
public static final String PREF_DATA_BOOTSTRAP_DONE = "pref_data_bootstrap_done";
|
||||
|
||||
/**
|
||||
* Boolean indicating if the app can collect Analytics.
|
||||
*/
|
||||
public static final String PREF_ANALYTICS_ENABLED = "pref_analytics_enabled";
|
||||
|
||||
/**
|
||||
* Mark that the app has finished loading the {@code R.raw.bootstrap_data bootstrap data}.
|
||||
*
|
||||
* @param context Context to be used to edit the {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
public static void markDataBootstrapDone(final Context context) {
|
||||
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
sp.edit().putBoolean(PREF_DATA_BOOTSTRAP_DONE, true).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the {@code R.raw.bootstrap_data_json bootstrap data} has been marked loaded.
|
||||
*
|
||||
* @param context Context to be used to lookup the {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
public static boolean isDataBootstrapDone(final Context context) {
|
||||
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
return sp.getBoolean(PREF_DATA_BOOTSTRAP_DONE, false);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Return true if user has already declined WiFi setup, but false if they haven't yet.
|
||||
// *
|
||||
// * @param context Context to be used to lookup the {@link android.content.SharedPreferences}.
|
||||
// */
|
||||
// public static boolean hasDeclinedWifiSetup(Context context) {
|
||||
// SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
// return sp.getBoolean(PREF_DECLINED_WIFI_SETUP, false);
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Mark that the user has explicitly declined WiFi setup assistance.
|
||||
// *
|
||||
// * @param context Context to be used to edit the {@link android.content.SharedPreferences}.
|
||||
// * @param newValue New value that will be set.
|
||||
// */
|
||||
// public static void markDeclinedWifiSetup(final Context context, boolean newValue) {
|
||||
// SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
// sp.edit().putBoolean(PREF_DECLINED_WIFI_SETUP, newValue).apply();
|
||||
// }
|
||||
|
||||
/**
|
||||
* Return true if analytics are enabled, false if user has disabled them.
|
||||
*
|
||||
* @param context Context to be used to lookup the {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
public static boolean isAnalyticsEnabled(final Context context) {
|
||||
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
return sp.getBoolean(PREF_ANALYTICS_ENABLED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to register a settings_prefs listener. This method does not automatically handle
|
||||
* {@code unregisterOnSharedPreferenceChangeListener() un-registering} the listener at the end
|
||||
* of the {@code context} lifecycle.
|
||||
*
|
||||
* @param context Context to be used to lookup the {@link android.content.SharedPreferences}.
|
||||
* @param listener Listener to register.
|
||||
*/
|
||||
public static void registerOnSharedPreferenceChangeListener(final Context context,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener listener) {
|
||||
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
sp.registerOnSharedPreferenceChangeListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to un-register a settings_prefs listener typically registered with
|
||||
* {@code registerOnSharedPreferenceChangeListener()}
|
||||
*
|
||||
* @param context Context to be used to lookup the {@link android.content.SharedPreferences}.
|
||||
* @param listener Listener to un-register.
|
||||
*/
|
||||
public static void unregisterOnSharedPreferenceChangeListener(final Context context,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener listener) {
|
||||
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
sp.unregisterOnSharedPreferenceChangeListener(listener);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.mozilla.hackathon.kiboko.sync;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.net.Uri;
|
||||
import android.os.RemoteException;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.mozilla.hackathon.kiboko.io.JSONHandler;
|
||||
import com.mozilla.hackathon.kiboko.io.TutorialsHandler;
|
||||
import com.mozilla.hackathon.kiboko.provider.DsoContract;
|
||||
import com.mozilla.hackathon.kiboko.services.SyncHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGD;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGE;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGW;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Helper class that parses conference data and imports them into the app's
|
||||
* Content Provider.
|
||||
*/
|
||||
public class DsoDataHandler {
|
||||
private static final String TAG = makeLogTag(SyncHelper.class);
|
||||
|
||||
// Shared settings_prefs key under which we store the timestamp that corresponds to
|
||||
// the data we currently have in our content provider.
|
||||
private static final String SP_KEY_DATA_TIMESTAMP = "data_timestamp";
|
||||
|
||||
// symbolic timestamp to use when we are missing timestamp data (which means our data is
|
||||
// really old or nonexistent)
|
||||
private static final String DEFAULT_TIMESTAMP = "Sat, 1 Jan 2000 00:00:00 GMT";
|
||||
|
||||
private static final String DATA_KEY_TUTORIALS = "tutorials";
|
||||
|
||||
private static final String[] DATA_KEYS_IN_ORDER = {
|
||||
DATA_KEY_TUTORIALS,
|
||||
|
||||
};
|
||||
|
||||
Context mContext = null;
|
||||
|
||||
// Handlers for each entity type:
|
||||
TutorialsHandler mTutorialsHandler = null;
|
||||
|
||||
|
||||
// Convenience map that maps the key name to its corresponding handler (e.g.
|
||||
// "blocks" to mBlocksHandler (to avoid very tedious if-elses)
|
||||
HashMap<String, JSONHandler> mHandlerForKey = new HashMap<String, JSONHandler>();
|
||||
|
||||
// Tally of total content provider operations we carried out (for statistical purposes)
|
||||
private int mContentProviderOperationsDone = 0;
|
||||
|
||||
public DsoDataHandler(Context ctx) {
|
||||
mContext = ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the conference data in the given objects and imports the data into the
|
||||
* content provider. The format of the data is documented at https://code.google.com/p/iosched.
|
||||
*
|
||||
* @param dataBodies The collection of JSON objects to parse and import.
|
||||
* @param dataTimestamp The timestamp of the data. This should be in RFC1123 format.
|
||||
* @param downloadsAllowed Whether or not we are supposed to download data from the internet if needed.
|
||||
* @throws IOException If there is a problem parsing the data.
|
||||
*/
|
||||
public void applyConferenceData(String[] dataBodies, String dataTimestamp,
|
||||
boolean downloadsAllowed) throws IOException {
|
||||
LOGD(TAG, "Applying data from " + dataBodies.length + " files, timestamp " + dataTimestamp);
|
||||
|
||||
// create handlers for each data type
|
||||
mHandlerForKey.put(DATA_KEY_TUTORIALS, mTutorialsHandler = new TutorialsHandler(mContext));
|
||||
|
||||
|
||||
// process the jsons. This will call each of the handlers when appropriate to deal
|
||||
// with the objects we see in the data.
|
||||
LOGD(TAG, "Processing " + dataBodies.length + " JSON objects.");
|
||||
for (int i = 0; i < dataBodies.length; i++) {
|
||||
LOGD(TAG, "Processing json object #" + (i + 1) + " of " + dataBodies.length);
|
||||
processDataBody(dataBodies[i]);
|
||||
}
|
||||
|
||||
// produce the necessary content provider operations
|
||||
ArrayList<ContentProviderOperation> batch = new ArrayList<ContentProviderOperation>();
|
||||
for (String key : DATA_KEYS_IN_ORDER) {
|
||||
LOGD(TAG, "Building content provider operations for: " + key);
|
||||
mHandlerForKey.get(key).makeContentProviderOperations(batch);
|
||||
LOGD(TAG, "Content provider operations so far: " + batch.size());
|
||||
}
|
||||
LOGD(TAG, "Total content provider operations: " + batch.size());
|
||||
|
||||
|
||||
// finally, push the changes into the Content Provider
|
||||
LOGD(TAG, "Applying " + batch.size() + " content provider operations.");
|
||||
try {
|
||||
int operations = batch.size();
|
||||
if (operations > 0) {
|
||||
mContext.getContentResolver().applyBatch(DsoContract.CONTENT_AUTHORITY, batch);
|
||||
}
|
||||
LOGD(TAG, "Successfully applied " + operations + " content provider operations.");
|
||||
mContentProviderOperationsDone += operations;
|
||||
} catch (RemoteException ex) {
|
||||
LOGE(TAG, "RemoteException while applying content provider operations.");
|
||||
throw new RuntimeException("Error executing content provider batch operation", ex);
|
||||
} catch (OperationApplicationException ex) {
|
||||
LOGE(TAG, "OperationApplicationException while applying content provider operations.");
|
||||
throw new RuntimeException("Error executing content provider batch operation", ex);
|
||||
}
|
||||
|
||||
// notify all top-level paths
|
||||
LOGD(TAG, "Notifying changes on all top-level paths on Content Resolver.");
|
||||
ContentResolver resolver = mContext.getContentResolver();
|
||||
for (String path : DsoContract.TOP_LEVEL_PATHS) {
|
||||
Uri uri = DsoContract.BASE_CONTENT_URI.buildUpon().appendPath(path).build();
|
||||
resolver.notifyChange(uri, null);
|
||||
}
|
||||
|
||||
|
||||
// update our data timestamp
|
||||
setDataTimestamp(dataTimestamp);
|
||||
LOGD(TAG, "Done applying conference data.");
|
||||
}
|
||||
|
||||
public int getContentProviderOperationsDone() {
|
||||
return mContentProviderOperationsDone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a conference data body and calls the appropriate data type handlers
|
||||
* to process each of the objects represented therein.
|
||||
*
|
||||
* @param dataBody The body of data to process
|
||||
* @throws IOException If there is an error parsing the data.
|
||||
*/
|
||||
private void processDataBody(String dataBody) throws IOException {
|
||||
JsonReader reader = new JsonReader(new StringReader(dataBody));
|
||||
JsonParser parser = new JsonParser();
|
||||
try {
|
||||
reader.setLenient(true); // To err is human
|
||||
|
||||
// the whole file is a single JSON object
|
||||
reader.beginObject();
|
||||
|
||||
while (reader.hasNext()) {
|
||||
// the key is "rooms", "speakers", "tracks", etc.
|
||||
String key = reader.nextName();
|
||||
if (mHandlerForKey.containsKey(key)) {
|
||||
// pass the value to the corresponding handler
|
||||
mHandlerForKey.get(key).process(parser.parse(reader));
|
||||
} else {
|
||||
LOGW(TAG, "Skipping unknown key in conference data json: " + key);
|
||||
reader.skipValue();
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the timestamp of the data we have in the content provider.
|
||||
public String getDataTimestamp() {
|
||||
return PreferenceManager.getDefaultSharedPreferences(mContext).getString(
|
||||
SP_KEY_DATA_TIMESTAMP, DEFAULT_TIMESTAMP);
|
||||
}
|
||||
|
||||
// Sets the timestamp of the data we have in the content provider.
|
||||
public void setDataTimestamp(String timestamp) {
|
||||
LOGD(TAG, "Setting data timestamp to: " + timestamp);
|
||||
PreferenceManager.getDefaultSharedPreferences(mContext).edit().putString(
|
||||
SP_KEY_DATA_TIMESTAMP, timestamp).commit();
|
||||
}
|
||||
|
||||
// Reset the timestamp of the data we have in the content provider
|
||||
public static void resetDataTimestamp(final Context context) {
|
||||
LOGD(TAG, "Resetting data timestamp to default (to invalidate our synced data)");
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().remove(
|
||||
SP_KEY_DATA_TIMESTAMP).commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of ConsoleRequestLogger that does not log requests and responses.
|
||||
*/
|
||||
private RequestLogger mQuietLogger = new ConsoleRequestLogger(){
|
||||
@Override
|
||||
public void logRequest(HttpURLConnection uc, Object content) throws IOException { }
|
||||
|
||||
@Override
|
||||
public void logResponse(HttpResponse res) { }
|
||||
};
|
||||
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package com.mozilla.hackathon.kiboko.sync;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.BuildConfig;
|
||||
import com.mozilla.hackathon.kiboko.services.SyncHelper;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGE;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGI;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Sync adapter for Google I/O data. Used for download sync only. For upload sync,
|
||||
*/
|
||||
public class SyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
private static final String TAG = makeLogTag(SyncAdapter.class);
|
||||
|
||||
private static final Pattern sSanitizeAccountNamePattern = Pattern.compile("(.).*?(.?)@");
|
||||
public static final String EXTRA_SYNC_DATA_ONLY =
|
||||
"com.mozilla.hackathon.kiboko.EXTRA_SYNC_DATA_ONLY";
|
||||
|
||||
private final Context mContext;
|
||||
|
||||
public SyncAdapter(Context context, boolean autoInitialize) {
|
||||
super(context, autoInitialize);
|
||||
mContext = context;
|
||||
|
||||
//noinspection ConstantConditions,PointlessBooleanExpression
|
||||
if (!BuildConfig.DEBUG) {
|
||||
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
|
||||
@Override
|
||||
public void uncaughtException(Thread thread, Throwable throwable) {
|
||||
LOGE(TAG, "Uncaught sync exception, suppressing UI in release build.",
|
||||
throwable);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(final Account account, Bundle extras, String authority,
|
||||
final ContentProviderClient provider, final SyncResult syncResult) {
|
||||
final boolean uploadOnly = extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false);
|
||||
final boolean initialize = extras.getBoolean(ContentResolver.SYNC_EXTRAS_INITIALIZE, false);
|
||||
final boolean userScheduleDataOnly = extras.getBoolean(EXTRA_SYNC_DATA_ONLY,
|
||||
false);
|
||||
|
||||
final String logSanitizedAccountName = sSanitizeAccountNamePattern
|
||||
.matcher(account.name).replaceAll("$1...$2@");
|
||||
|
||||
// This Adapter is declared not to support uploading in its xml file.
|
||||
// {@code ContentResolver.SYNC_EXTRAS_UPLOAD} is set by the system in some cases, but never
|
||||
// by the app. Conference data only is a download sync, user schedule data sync is both
|
||||
// ways and uses {@code EXTRA_SYNC_USER_DATA_ONLY}, session feedback data sync is
|
||||
// upload only and isn't managed by this SyncAdapter as it doesn't need periodic sync.
|
||||
if (uploadOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOGI(TAG, "Beginning sync for account " + logSanitizedAccountName + "," +
|
||||
" uploadOnly=" + uploadOnly +
|
||||
" userScheduleDataOnly =" + userScheduleDataOnly +
|
||||
" initialize=" + initialize);
|
||||
|
||||
// Sync from bootstrap and remote data, as needed
|
||||
new SyncHelper(mContext).performSync(syncResult, account, extras);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package com.mozilla.hackathon.kiboko.sync;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
/**
|
||||
* Service that handles sync. It simply instantiates a SyncAdapter and returns its IBinder.
|
||||
*/
|
||||
public class SyncService extends Service {
|
||||
private static final Object sSyncAdapterLock = new Object();
|
||||
private static SyncAdapter sSyncAdapter = null;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
synchronized (sSyncAdapterLock) {
|
||||
if (sSyncAdapter == null) {
|
||||
sSyncAdapter = new SyncAdapter(getApplicationContext(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return sSyncAdapter.getSyncAdapterBinder();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
package com.mozilla.hackathon.kiboko.utilities;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.LOGV;
|
||||
import static com.mozilla.hackathon.kiboko.utilities.LogUtils.makeLogTag;
|
||||
|
||||
/**
|
||||
* Helper for building selection clauses for {@link SQLiteDatabase}. Each
|
||||
* appended clause is combined using {@code AND}. This class is <em>not</em>
|
||||
* thread safe.
|
||||
*/
|
||||
public class SelectionBuilder {
|
||||
private static final String TAG = makeLogTag(SelectionBuilder.class);
|
||||
|
||||
private String mTable = null;
|
||||
private Map<String, String> mProjectionMap = new HashMap<>();
|
||||
private StringBuilder mSelection = new StringBuilder();
|
||||
private ArrayList<String> mSelectionArgs = new ArrayList<>();
|
||||
private String mGroupBy = null;
|
||||
private String mHaving = null;
|
||||
|
||||
/**
|
||||
* Reset any internal state, allowing this builder to be recycled.
|
||||
*/
|
||||
public SelectionBuilder reset() {
|
||||
mTable = null;
|
||||
mGroupBy = null;
|
||||
mHaving = null;
|
||||
mSelection.setLength(0);
|
||||
mSelectionArgs.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the given selection clause to the internal state. Each clause is
|
||||
* surrounded with parenthesis and combined using {@code AND}.
|
||||
*/
|
||||
public SelectionBuilder where(String selection, String... selectionArgs) {
|
||||
if (TextUtils.isEmpty(selection)) {
|
||||
if (selectionArgs != null && selectionArgs.length > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Valid selection required when including arguments=");
|
||||
}
|
||||
|
||||
// Shortcut when clause is empty
|
||||
return this;
|
||||
}
|
||||
|
||||
if (mSelection.length() > 0) {
|
||||
mSelection.append(" AND ");
|
||||
}
|
||||
|
||||
mSelection.append("(").append(selection).append(")");
|
||||
if (selectionArgs != null) {
|
||||
Collections.addAll(mSelectionArgs, selectionArgs);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public SelectionBuilder groupBy(String groupBy) {
|
||||
mGroupBy = groupBy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SelectionBuilder having(String having) {
|
||||
mHaving = having;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SelectionBuilder table(String table) {
|
||||
mTable = table;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace positional params in table. Use for JOIN ON conditions.
|
||||
*/
|
||||
public SelectionBuilder table(String table, String... tableParams) {
|
||||
if (tableParams != null && tableParams.length > 0) {
|
||||
String[] parts = table.split("[?]", tableParams.length+1);
|
||||
StringBuilder sb = new StringBuilder(parts[0]);
|
||||
for (int i=1; i<parts.length; i++) {
|
||||
sb.append('"').append(tableParams[i-1]).append('"')
|
||||
.append(parts[i]);
|
||||
}
|
||||
mTable = sb.toString();
|
||||
} else {
|
||||
mTable = table;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private void assertTable() {
|
||||
if (mTable == null) {
|
||||
throw new IllegalStateException("Table not specified");
|
||||
}
|
||||
}
|
||||
|
||||
public SelectionBuilder mapToTable(String column, String table) {
|
||||
mProjectionMap.put(column, table + "." + column);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SelectionBuilder map(String fromColumn, String toClause) {
|
||||
mProjectionMap.put(fromColumn, toClause + " AS " + fromColumn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return selection string for current internal state.
|
||||
*
|
||||
* @see #getSelectionArgs()
|
||||
*/
|
||||
public String getSelection() {
|
||||
return mSelection.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return selection arguments for current internal state.
|
||||
*
|
||||
* @see #getSelection()
|
||||
*/
|
||||
public String[] getSelectionArgs() {
|
||||
return mSelectionArgs.toArray(new String[mSelectionArgs.size()]);
|
||||
}
|
||||
|
||||
private void mapColumns(String[] columns) {
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
final String target = mProjectionMap.get(columns[i]);
|
||||
if (target != null) {
|
||||
columns[i] = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SelectionBuilder[table=" + mTable + ", selection=" + getSelection()
|
||||
+ ", selectionArgs=" + Arrays.toString(getSelectionArgs())
|
||||
+ "projectionMap = " + mProjectionMap + " ]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query using the current internal state as {@code WHERE} clause.
|
||||
*/
|
||||
public Cursor query(SQLiteDatabase db, String[] columns, String orderBy) {
|
||||
return query(db, false, columns, orderBy, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query using the current internal state as {@code WHERE} clause.
|
||||
*/
|
||||
public Cursor query(SQLiteDatabase db, boolean distinct, String[] columns, String orderBy,
|
||||
String limit) {
|
||||
assertTable();
|
||||
if (columns != null) mapColumns(columns);
|
||||
LOGV(TAG, "query(columns=" + Arrays.toString(columns)
|
||||
+ ", distinct=" + distinct + ") " + this);
|
||||
return db.query(distinct, mTable, columns, getSelection(), getSelectionArgs(), mGroupBy,
|
||||
mHaving, orderBy, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute update using the current internal state as {@code WHERE} clause.
|
||||
*/
|
||||
public int update(SQLiteDatabase db, ContentValues values) {
|
||||
assertTable();
|
||||
LOGV(TAG, "update() " + this);
|
||||
return db.update(mTable, values, getSelection(), getSelectionArgs());
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute delete using the current internal state as {@code WHERE} clause.
|
||||
*/
|
||||
public int delete(SQLiteDatabase db) {
|
||||
assertTable();
|
||||
LOGV(TAG, "delete() " + this);
|
||||
return db.delete(mTable, getSelection(), getSelectionArgs());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package com.mozilla.hackathon.kiboko.utilities;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
|
||||
import com.mozilla.hackathon.kiboko.settings.SettingsUtils;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Formatter;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class TimeUtils {
|
||||
public static final int SECOND = 1000;
|
||||
public static final int MINUTE = 60 * SECOND;
|
||||
public static final int HOUR = 60 * MINUTE;
|
||||
public static final int DAY = 24 * HOUR;
|
||||
|
||||
private static final SimpleDateFormat[] ACCEPTED_TIMESTAMP_FORMATS = {
|
||||
new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US),
|
||||
new SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US),
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss Z", Locale.US)
|
||||
};
|
||||
|
||||
private static final SimpleDateFormat VALID_IFMODIFIEDSINCE_FORMAT =
|
||||
new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
|
||||
|
||||
public static Date parseTimestamp(String timestamp) {
|
||||
for (SimpleDateFormat format : ACCEPTED_TIMESTAMP_FORMATS) {
|
||||
// TODO: We shouldn't be forcing the time zone when parsing dates.
|
||||
format.setTimeZone(TimeZone.getTimeZone("GMT"));
|
||||
try {
|
||||
return format.parse(timestamp);
|
||||
} catch (ParseException ex) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts to parse have failed
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isValidFormatForIfModifiedSinceHeader(String timestamp) {
|
||||
try {
|
||||
return VALID_IFMODIFIEDSINCE_FORMAT.parse(timestamp)!=null;
|
||||
} catch (Exception ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static long timestampToMillis(String timestamp, long defaultValue) {
|
||||
if (TextUtils.isEmpty(timestamp)) {
|
||||
return defaultValue;
|
||||
}
|
||||
Date d = parseTimestamp(timestamp);
|
||||
return d == null ? defaultValue : d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a {@code date} honoring the app preference for using Conference or device timezone.
|
||||
* {@code Context} is used to lookup the shared preference settings.
|
||||
*/
|
||||
|
||||
// public static String formatShortDate(Context context, Date date) {
|
||||
// StringBuilder sb = new StringBuilder();
|
||||
// Formatter formatter = new Formatter(sb);
|
||||
// return DateUtils.formatDateRange(context, formatter, date.getTime(), date.getTime(),
|
||||
// DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_NO_YEAR,
|
||||
// SettingsUtils.getDisplayTimeZone(context).getID()).toString();
|
||||
// }
|
||||
|
||||
// public static String formatShortTime(Context context, Date time) {
|
||||
// // Android DateFormatter will honor the user's current settings.
|
||||
// DateFormat format = android.text.format.DateFormat.getTimeFormat(context);
|
||||
// // Override with Timezone based on settings since users can override their phone's timezone
|
||||
// // with Pacific time zones.
|
||||
// TimeZone tz = SettingsUtils.getDisplayTimeZone(context);
|
||||
// if (tz != null) {
|
||||
// format.setTimeZone(tz);
|
||||
// }
|
||||
// return format.format(time);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Returns "Today", "Tomorrow", "Yesterday", or a short date format.
|
||||
*/
|
||||
// public static String formatHumanFriendlyShortDate(final Context context, long timestamp) {
|
||||
// long localTimestamp, localTime;
|
||||
// long now = UIUtils.getCurrentTime(context);
|
||||
//
|
||||
// TimeZone tz = SettingsUtils.getDisplayTimeZone(context);
|
||||
// localTimestamp = timestamp + tz.getOffset(timestamp);
|
||||
// localTime = now + tz.getOffset(now);
|
||||
//
|
||||
// long dayOrd = localTimestamp / 86400000L;
|
||||
// long nowOrd = localTime / 86400000L;
|
||||
//
|
||||
// if (dayOrd == nowOrd) {
|
||||
// return context.getString(R.string.day_title_today);
|
||||
// } else if (dayOrd == nowOrd - 1) {
|
||||
// return context.getString(R.string.day_title_yesterday);
|
||||
// } else if (dayOrd == nowOrd + 1) {
|
||||
// return context.getString(R.string.day_title_tomorrow);
|
||||
// } else {
|
||||
// return formatShortDate(context, new Date(timestamp));
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"tutorials": [
|
||||
{
|
||||
"id": "1",
|
||||
"tag": "wifi",
|
||||
"header": "Wi-Fi ni Noma!",
|
||||
"photoUrl": "url",
|
||||
|
@ -9,8 +10,7 @@
|
|||
"id": "1",
|
||||
"title": "What is Wi-Fi?",
|
||||
"gifUrl": "url",
|
||||
"description": "Wi-Fi is a technology that helps your phone connect to the internet, similar to airtime or mobile data. However, <b>Wi-Fi doesn’t use your airtime or mobile data.</b> Instead, in coffee shops or public spaces, Wi-Fi is often free.
|
||||
"
|
||||
"description": "Wi-Fi is a technology that helps your phone connect to the internet, similar to airtime or mobile data. However, <b>Wi-Fi doesn’t use your airtime or mobile data.</b> Instead, in coffee shops or public spaces, Wi-Fi is often free."
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
|
@ -33,28 +33,64 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"tag": "connect_wifi",
|
||||
"header": "Connecting to Wi-Fi",
|
||||
"photoUrl": "url",
|
||||
"steps": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "What is Wi-Fi?",
|
||||
"gifUrl": "url",
|
||||
"description": "Wi-Fi is a technology that helps your phone connect to the internet, similar to airtime or mobile data. However, <b>Wi-Fi doesn’t use your airtime or mobile data.</b> Instead, in coffee shops or public spaces, Wi-Fi is often free.
|
||||
"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "Finding Names and Passwords",
|
||||
"gifUrl": "url",
|
||||
"description": "To connect to a Wi-Fi hotspot, you will need the name of the hotspot and, in some cases, its password. If you are in a business, cafe, or other public space, you can usually ask the owner of the hotspot for this information. Some places charge a small fee for using their Wi-Fi."
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"id": "2",
|
||||
"title": "Turning on Wi-Fi",
|
||||
"gifUrl": "url",
|
||||
"description": "On most Android phones, you can begin looking for Wi-Fi by swiping down the Title Bar at the top of the screen and tapping the Wi-Fi icon. If you are close to a Wi-Fi network you have joined in the past, your phone will try to connect to it automatically."
|
||||
"description": "On most Android phones, you can begin looking for Wi-Fi by swiping down the Title Bar at the top of the screen and tapping the Wi-Fi icon. If you are close to a Wi-Fi network you have joined in the past, <b>your phone will try to connect to it automatically</b>."
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"title": "Turning on Wi-Fi from the Wireless Settings Menu",
|
||||
"gifUrl": "url",
|
||||
"description": "On Android phones, to make sure your Wi-Fi is on, open the <b>Settings</b> app [icon], and tap <b>Wi-Fi</b> icon [icon]. If your Wi-Fi is OFF, look near the top of the screen and then tap the toggle switch [cropped screenshot] to move it into the “ON” position."
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"title": "Finding your hotspot in the Wi-Fi list",
|
||||
"gifUrl": "url",
|
||||
"description": "If your phone hasn’t already automatically connected to a Wi-Fi hotspot, you will need to use your phone to search for the hotspot you want to connect to.\n If you are already in the Wi-Fi Settings Menu from the last step, simply wait until you see the name of the Wi-Fi hotspot that you want to connect to. Otherwise, open the Settings app [icon], and click on the Wi-Fi icon [icon]. If your Wi-Fi is on, you will see a list of all the Wi-Fi hotspots around you."
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"title": "Connecting to a Wi-Fi hotspot",
|
||||
"gifUrl": "url",
|
||||
"description": "When you see the Wi-Fi hotspot that you want to connect to, tap it. If it doesn’t require a password, your phone will try to connect. When it does, it will read <b>Connected</b>.\nYou may also see your phone say <b>Authenticating</b> or <b>Obtaining IP Address</b>. These are normal steps in making a Wi-Fi connection.\nIf asked for a password, usishtuke! Just go to the next step [button to next step]."
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"title": "Entering a Password",
|
||||
"gifUrl": "url",
|
||||
"description": "<i>If your phone has already connected to the correct Wi-Fi hotspot, you can skip this step, and just go enjoy the Internet. :)</i>\nSome Wi-Fi hotspots require a password. In this case, a box will appear that asks for a password. If you don’t know the password, ask for help from the owner of the hotspot. Once you have the password, use the keyboard that pops up to enter it.\nIf you have trouble, jump to Fixing Problems."
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"title": "Fixing Problems",
|
||||
"gifUrl": "url",
|
||||
"description": "If you have problems connecting to a Wi-Fi hotspot, here is a list of things you can try:<ul><li>Make sure you have the right hotspot name.</li>Make sure you have the right password.</li>If you have the right password, make sure it’s entered correctly.</li>Talk to the hotspot owner (shopkeeper, librarian, IT assistant, etc.) and explain your problems. They may be able to help solve your problem.</li></ul>"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"title": "Fixing Problems Continued",
|
||||
"gifUrl": "url",
|
||||
"description": "Sometimes even with the correct hotspot name and password, connection problems will persist. In this case, you can try removing the hotspot from your phone’s memory and then reconnecting. Go to the Wi-Fi Settings Menu (see Frame 3 [link]), and find the problematic hotspot’s name. Then, press and hold on the hotspot’s name until a menu appears. Choose “Forget Network”. Then, try connecting again. You may have to re-enter your password, so have it ready.\nSometimes a hotspot will be set up to work with specific devices and it may not let you connect to the internet even though it looks like it should. If you really can’t connect to the one you picked, try to find another hotspot."
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"title": "Enjoy",
|
||||
"gifUrl": "url",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The attributes in this XML file provide configuration information for the SyncAdapter. -->
|
||||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:contentAuthority="com.mozilla.hackathon.kiboko"
|
||||
android:supportsUploading="false" />
|
|
@ -5,7 +5,7 @@ buildscript {
|
|||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:2.1.0'
|
||||
classpath 'com.android.tools.build:gradle:2.1.2'
|
||||
classpath 'com.google.gms:google-services:3.0.0'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
|
|
@ -1,18 +1,76 @@
|
|||
# Project-wide Gradle settings.
|
||||
#
|
||||
# Properties for the build which can be overridden locally.
|
||||
#
|
||||
# This allows build keys to be set where the app is being built in
|
||||
# a gradle.properties override. See;
|
||||
#
|
||||
# http://www.gradle.org/docs/current/userguide/tutorial_this_and_that.html#sec:gradle_properties_and_system_properties
|
||||
#
|
||||
# for more information on the overriding system.
|
||||
#
|
||||
###############################################################################
|
||||
# App variables.
|
||||
###############################################################################
|
||||
# If versions end in odd numbers they are development builds, even versions are release candidates.
|
||||
# The AndroidManifest.xml must also be updated currently.
|
||||
version_code = 1
|
||||
version_name = 1.0.0
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# Using these variables to sync dependency version numbers across sub-projects.
|
||||
android_support_lib_version = 23.0.1
|
||||
google_play_services_client_library_version = 7.0.0
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx10248m -XX:MaxPermSize=256m
|
||||
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
# The store file location is relative to the module base, and so needs to go
|
||||
# up one level of the directory hierarchy to get to the project root.
|
||||
mozilladso_android_debugkey_storefile = ../android/debug.keystore
|
||||
mozilladso_android_debugkey_storePassword = android
|
||||
mozilladso_android_debugkey_keyAlias = androiddebugkey
|
||||
mozilladso_android_debugkey_keyPassword = android
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
mozilladso_android_releasekey_storefile = ../android/debug.keystore
|
||||
mozilladso_android_releasekey_storePassword = android
|
||||
mozilladso_android_releasekey_keyAlias = androiddebugkey
|
||||
mozilladso_android_releasekey_keyPassword = android
|
||||
|
||||
###############################################################################
|
||||
# Prod/Staging/Test/Dev Environment Variables.
|
||||
###############################################################################
|
||||
# API manifest URLs. These URLs provide the data files to load to download data for the app.
|
||||
# When data needs to change the underlying data file is published as a new revision and the manifest
|
||||
# is updated with the new file name.
|
||||
staging_api_manifest_endpoint = http://storage.googleapis.com/io2015-data.appspot.com/manifest_v1.json
|
||||
production_api_manifest_endpoint = http://storage.googleapis.com/io2015-data.appspot.com/manifest_v1.json
|
||||
|
||||
# GCM server endpoints to checkin with.
|
||||
staging_gcm_server_endpoint = https://io2015-data.appspot.com/gcm
|
||||
production_gcm_server_endpoint = https://io2015-data.appspot.com/gcm
|
||||
|
||||
# Website hostname
|
||||
staging_website_host_name = googleio-staging.appspot.com
|
||||
production_website_host_name = events.google.com
|
||||
|
||||
#API key for GCM
|
||||
## TODO: Supply GCM API key and sender ID for your project
|
||||
gcm_api_key = UNDEFINED
|
||||
gcm_sender_id = UNDEFINED
|
||||
|
||||
# Used for generic API method calls to Google services, including Maps.
|
||||
## TODO: Supply Google API key and sender ID for your project
|
||||
oauth2_creds_api_key = UNDEFINED
|
||||
|
||||
## TODO: Supply YouTube API key and sender ID for your project
|
||||
youtube_api_key = UNDEFINED
|
||||
|
||||
metadata_url = http://url-caster.appspot.com/resolve-scan
|
||||
|
||||
# Feedback API
|
||||
## TODO: Supply Feedback API endpoint and implement FeedbackApiHelper
|
||||
feedback_api_endpoint = UNDEFINED
|
||||
|
||||
bootstrap_data_timestamp = Thu, 23 June 2016 00:01:03 GMT
|
||||
|
||||
###############################################################################
|
||||
# Test parameter values.
|
||||
###############################################################################
|
||||
test_youtube_live_url= https://www.youtube.com/watch?v=iGTIK_8ydoI
|
Загрузка…
Ссылка в новой задаче