1
0
Fork 0
mirror of https://github.com/MatomoCamp/matomocamp-companion-android.git synced 2024-09-19 16:13:46 +02:00

Added bookmarks export to iCalendar file, closes #5

This commit is contained in:
Christophe Beyls 2017-01-06 21:13:05 +01:00
parent 9e4efd69e2
commit 01e1c25f36
8 changed files with 387 additions and 10 deletions

View file

@ -117,6 +117,11 @@
android:name=".providers.SearchSuggestionProvider"
android:authorities="${applicationId}.search"
android:exported="true"/>
<provider
android:name=".providers.BookmarksExportProvider"
android:authorities="${applicationId}.bookmarks"
android:exported="false"
android:grantUriPermissions="true"/>
</application>
</manifest>

View file

@ -1,7 +1,9 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.support.v4.app.LoaderManager.LoaderCallbacks;
@ -20,6 +22,7 @@ import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.BookmarksAdapter;
import be.digitalia.fosdem.db.DatabaseManager;
import be.digitalia.fosdem.loaders.SimpleCursorLoader;
import be.digitalia.fosdem.providers.BookmarksExportProvider;
/**
* Bookmarks list, optionally filterable.
@ -85,10 +88,13 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade
inflater.inflate(R.menu.bookmarks, menu);
filterMenuItem = menu.findItem(R.id.filter);
upcomingOnlyMenuItem = menu.findItem(R.id.upcoming_only);
updateOptionsMenu();
updateFilterMenuItem();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.GINGERBREAD) {
menu.findItem(R.id.export_bookmarks).setEnabled(false).setVisible(false);
}
}
private void updateOptionsMenu() {
private void updateFilterMenuItem() {
if (filterMenuItem != null) {
filterMenuItem.setIcon(upcomingOnly ?
R.drawable.ic_filter_list_selected_white_24dp
@ -109,12 +115,16 @@ public class BookmarksListFragment extends RecyclerViewFragment implements Loade
switch (item.getItemId()) {
case R.id.upcoming_only:
upcomingOnly = !upcomingOnly;
updateOptionsMenu();
updateFilterMenuItem();
SharedPreferencesCompat.EditorCompat.getInstance().apply(
getActivity().getPreferences(Context.MODE_PRIVATE).edit().putBoolean(PREF_UPCOMING_ONLY, upcomingOnly)
);
getLoaderManager().restartLoader(BOOKMARKS_LOADER_ID, null, this);
return true;
case R.id.export_bookmarks:
Intent exportIntent = BookmarksExportProvider.getIntent(getActivity());
startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks)));
return true;
}
return false;
}

View file

@ -0,0 +1,234 @@
package be.digitalia.fosdem.providers;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Intent;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.ShareCompat;
import android.text.TextUtils;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.TimeZone;
import be.digitalia.fosdem.BuildConfig;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.api.FosdemUrls;
import be.digitalia.fosdem.db.DatabaseManager;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.utils.ICalendarWriter;
import be.digitalia.fosdem.utils.StringUtils;
/**
* Content Provider generating the current bookmarks list in iCalendar format.
*/
public class BookmarksExportProvider extends ContentProvider {
private static final Uri URI = new Uri.Builder()
.scheme("content")
.authority(BuildConfig.APPLICATION_ID + ".bookmarks")
.appendPath("bookmarks.ics")
.build();
private static final String TYPE = "text/calendar";
private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
public static Intent getIntent(Activity activity) {
final Intent exportIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
// Supports granting read permission for the attached shared file
exportIntent = ShareCompat.IntentBuilder.from(activity)
.setStream(URI)
.setType(TYPE)
.getIntent();
} else {
// Fallback: open file directly
exportIntent = new Intent(Intent.ACTION_VIEW).setDataAndType(URI, TYPE);
}
exportIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
return exportIntent;
}
@Override
public boolean onCreate() {
return true;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, ContentValues values) {
throw new UnsupportedOperationException();
}
@Override
public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException();
}
@Override
public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException();
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return TYPE;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
if (projection == null) {
projection = COLUMNS;
}
String[] cols = new String[projection.length];
Object[] values = new Object[projection.length];
int i = 0;
for (String col : projection) {
if (OpenableColumns.DISPLAY_NAME.equals(col)) {
cols[i] = OpenableColumns.DISPLAY_NAME;
values[i++] = getContext().getString(R.string.export_bookmarks_file_name, DatabaseManager.getInstance().getYear());
} else if (OpenableColumns.SIZE.equals(col)) {
cols[i] = OpenableColumns.SIZE;
// Unknown size, content will be generated on-the-fly
values[i++] = 1024L;
}
}
cols = copyOf(cols, i);
values = copyOf(values, i);
final MatrixCursor cursor = new MatrixCursor(cols, 1);
cursor.addRow(values);
return cursor;
}
@TargetApi(Build.VERSION_CODES.GINGERBREAD)
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.GINGERBREAD) {
throw new FileNotFoundException("Bookmarks export is not supported for this Android version");
}
try {
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
new DownloadThread(new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])).start();
return pipe[0];
} catch (IOException e) {
throw new FileNotFoundException("Could not open pipe");
}
}
private static String[] copyOf(String[] original, int newLength) {
final String[] result = new String[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
private static Object[] copyOf(Object[] original, int newLength) {
final Object[] result = new Object[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
static class DownloadThread extends Thread {
private final ICalendarWriter writer;
private final DateFormat dateFormat;
private final String dtStamp;
private final TextUtils.StringSplitter personsSplitter = new StringUtils.SimpleStringSplitter(", ");
DownloadThread(OutputStream out) {
this.writer = new ICalendarWriter(new BufferedWriter(new OutputStreamWriter(out)));
// Format all times in GMT
this.dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US);
this.dateFormat.setTimeZone(TimeZone.getTimeZone("GMT+0"));
this.dtStamp = dateFormat.format(System.currentTimeMillis());
}
@Override
public void run() {
try {
final Cursor cursor = DatabaseManager.getInstance().getBookmarks(0L);
try {
writer.write("BEGIN", "VCALENDAR");
writer.write("VERSION", "2.0");
writer.write("PRODID", "-//" + BuildConfig.APPLICATION_ID + "//NONSGML " + BuildConfig.VERSION_NAME + "//EN");
Event event = null;
while (cursor.moveToNext()) {
event = DatabaseManager.toEvent(cursor, event);
writeEvent(event);
}
writer.write("END", "VCALENDAR");
} finally {
cursor.close();
}
} catch (Exception ignore) {
} finally {
try {
writer.close();
} catch (IOException ignore) {
}
}
}
private void writeEvent(Event event) throws IOException {
writer.write("BEGIN", "VEVENT");
final int year = DatabaseManager.getInstance().getYear();
writer.write("UID", String.format(Locale.US, "%1$d@%2$d@%3$s", event.getId(), year, BuildConfig.APPLICATION_ID));
writer.write("DTSTAMP", dtStamp);
if (event.getStartTime() != null) {
writer.write("DTSTART", dateFormat.format(event.getStartTime()));
}
if (event.getEndTime() != null) {
writer.write("DTEND", dateFormat.format(event.getEndTime()));
}
writer.write("SUMMARY", event.getTitle());
String description = event.getAbstractText();
if (TextUtils.isEmpty(description)) {
description = event.getDescription();
}
if (!TextUtils.isEmpty(description)) {
writer.write("DESCRIPTION", StringUtils.stripHtml(description));
writer.write("X-ALT-DESC", description);
}
writer.write("CLASS", "PUBLIC");
writer.write("CATEGORIES", event.getTrack().getName());
writer.write("URL", event.getUrl());
writer.write("LOCATION", event.getRoomName());
personsSplitter.setString(event.getPersonsSummary());
for (String name : personsSplitter) {
String key = String.format(Locale.US, "ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN=\"%1$s\"", name);
String url = FosdemUrls.getPerson(StringUtils.toSlug(name), year);
writer.write(key, url);
}
writer.write("END", "VEVENT");
}
}
}

View file

@ -0,0 +1,57 @@
package be.digitalia.fosdem.utils;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.Closeable;
import java.io.IOException;
import java.io.Writer;
/**
* Simple wrapper to write to iCalendar file format.
*/
public class ICalendarWriter implements Closeable {
private static final String CRLF = "\r\n";
private final Writer writer;
public ICalendarWriter(@NonNull Writer writer) {
this.writer = writer;
}
public void write(@NonNull String key, @Nullable String value) throws IOException {
if (value != null) {
writer.write(key);
writer.write(':');
// Escape line break sequences
final int length = value.length();
int start = 0;
int end = 0;
while (end < length) {
final char c = value.charAt(end);
if (c == '\r' || c == '\n') {
writer.write(value, start, end - start);
writer.write(CRLF);
writer.write(' ');
do {
end++;
}
while ((end < length) && (value.charAt(end) == '\r' || value.charAt(end) == '\n'));
start = end;
} else {
end++;
}
}
writer.write(value, start, end - start);
writer.write(CRLF);
}
}
@Override
public void close() throws IOException {
writer.close();
}
}

View file

@ -1,15 +1,18 @@
package be.digitalia.fosdem.utils;
import android.content.res.Resources;
import android.support.annotation.NonNull;
import android.support.v4.util.CircularIntArray;
import android.text.Editable;
import android.text.Html;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BulletSpan;
import android.text.style.LeadingMarginSpan;
import org.xml.sax.XMLReader;
import java.util.Iterator;
import java.util.Locale;
/**
@ -33,7 +36,7 @@ public class StringUtils {
* @param source string to convert
* @return corresponding string without diacritics
*/
public static String removeDiacritics(String source) {
public static String removeDiacritics(@NonNull String source) {
final int length = source.length();
char[] result = new char[length];
char c;
@ -91,21 +94,21 @@ public class StringUtils {
/**
* Transforms a name to a slug identifier to be used in a FOSDEM URL.
*/
public static String toSlug(String source) {
public static String toSlug(@NonNull String source) {
return replaceNonAlphaGroups(trimNonAlpha(removeDiacritics(source)), '_').toLowerCase(Locale.US);
}
@SuppressWarnings("deprecation")
public static String stripHtml(String html) {
public static String stripHtml(@NonNull String html) {
return trimEnd(Html.fromHtml(html)).toString();
}
@SuppressWarnings("deprecation")
public static CharSequence parseHtml(String html, Resources res) {
public static CharSequence parseHtml(@NonNull String html, Resources res) {
return trimEnd(Html.fromHtml(html, null, new ListsTagHandler(res)));
}
public static CharSequence trimEnd(CharSequence source) {
public static CharSequence trimEnd(@NonNull CharSequence source) {
int pos = source.length() - 1;
while ((pos >= 0) && Character.isWhitespace(source.charAt(pos))) {
pos--;
@ -118,7 +121,7 @@ public class StringUtils {
* Converts a room name to a local drawable resource name, by stripping non-alpha chars and converting to lower case. Any letter following a digit will be
* ignored, along with the rest of the string.
*/
public static String roomNameToResourceName(String roomName) {
public static String roomNameToResourceName(@NonNull String roomName) {
StringBuilder builder = new StringBuilder(ROOM_DRAWABLE_PREFIX.length() + roomName.length());
builder.append(ROOM_DRAWABLE_PREFIX);
int size = roomName.length();
@ -200,4 +203,56 @@ public class StringUtils {
}
}
}
/**
* A version of Android's SimpleStringSplitter using a String as delimiter.
*/
public static class SimpleStringSplitter implements TextUtils.StringSplitter, Iterator<String> {
private final String mDelimiter;
private String mString;
private int mPosition;
private int mLength;
/**
* Initializes the splitter. setString may be called later.
*
* @param delimiter the delimiter on which to split
*/
public SimpleStringSplitter(String delimiter) {
mDelimiter = delimiter;
}
/**
* Sets the string to split
*
* @param string the string to split
*/
public void setString(String string) {
mString = string;
mPosition = 0;
mLength = mString.length();
}
public Iterator<String> iterator() {
return this;
}
public boolean hasNext() {
return mPosition < mLength;
}
public String next() {
int end = mString.indexOf(mDelimiter, mPosition);
if (end == -1) {
end = mLength;
}
String nextString = mString.substring(mPosition, end);
mPosition = end + mDelimiter.length(); // Skip the delimiter.
return nextString;
}
public void remove() {
throw new UnsupportedOperationException();
}
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z"/>
</vector>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" >
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/filter"
@ -13,5 +13,10 @@
android:title="@string/upcoming_only"/>
</menu>
</item>
<item
android:id="@+id/export_bookmarks"
android:icon="@drawable/ic_file_upload_white_24dp"
android:title="@string/export_bookmarks"
app:showAsAction="ifRoom"/>
</menu>

View file

@ -28,6 +28,8 @@
<!-- Bookmarks -->
<string name="filter">Filter</string>
<string name="upcoming_only">Upcoming only</string>
<string name="export_bookmarks">Export bookmarks</string>
<string name="export_bookmarks_file_name">FOSDEM %1$d bookmarks.ics</string>
<string name="no_bookmark">No bookmark.</string>
<string name="remove_bookmarks">Remove bookmarks</string>
<string name="bookmark_conflict_content_description">%1$s\n Other bookmarks are scheduled at the same time.</string>