Add support for OPML files import/export - Fix #239

This commit is contained in:
David Development 2016-01-14 15:36:15 +01:00
parent 0fa4e1172e
commit bf2049ff50
7 changed files with 490 additions and 25 deletions

View file

@ -58,6 +58,7 @@ repositories {
mavenCentral()
jcenter()
maven { url 'http://guardian.github.com/maven/repo-releases' } //needed for com.gu:option:1.3 in Android-DirectoryChooser
maven { url "http://dl.bintray.com/lukaville/maven" } //Needed for com.nbsp:library:1.02 in Material File Picker
}
@ -106,4 +107,5 @@ dependencies {
exclude module: 'recyclerview-v7'
}
compile project(':MaterialShowcaseView:library')
compile 'com.nbsp:library:1.02'
}

View file

@ -76,6 +76,15 @@
<data android:pathPattern=".*\\atom.xml" />
<data android:pathPattern=".*\\rss.xml" />
<data android:pathPattern=".*\\.rss" />
<data android:scheme="http" android:host="*"
android:pathPattern=".*\\.opml" />
<data android:scheme="https" android:host="*"
android:pathPattern=".*\\.opml" />
<data android:scheme="content" android:host="*"
android:pathPattern=".*\\.opml" />
<data android:scheme="file" android:host="*"
android:pathPattern=".*\\.opml" />
</intent-filter>
<intent-filter>

View file

@ -2,31 +2,57 @@ package de.luhmer.owncloudnewsreader;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.text.TextUtils;
import android.util.Log;
import android.util.Xml;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.inputmethod.InputMethodManager;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.Toast;
import com.nbsp.materialfilepicker.ui.FilePickerActivity;
import org.json.JSONArray;
import org.json.JSONObject;
import org.xmlpull.v1.XmlPullParser;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import butterknife.ButterKnife;
import butterknife.InjectView;
import butterknife.OnClick;
import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm;
import de.luhmer.owncloudnewsreader.database.model.Folder;
import de.luhmer.owncloudnewsreader.helper.AsyncTaskHelper;
import de.luhmer.owncloudnewsreader.helper.FileUtils;
import de.luhmer.owncloudnewsreader.helper.OpmlXmlParser;
import de.luhmer.owncloudnewsreader.helper.ThemeChooser;
import de.luhmer.owncloudnewsreader.helper.URLConnectionReader;
import de.luhmer.owncloudnewsreader.model.Tuple;
import de.luhmer.owncloudnewsreader.reader.HttpJsonRequest;
import de.luhmer.owncloudnewsreader.reader.owncloud.API;
import de.luhmer.owncloudnewsreader.reader.owncloud.apiv2.APIv2;
@ -75,21 +101,6 @@ public class NewFeedActivity extends AppCompatActivity {
ArrayAdapter<String> spinnerArrayAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, folderNames);
mFolderView.setAdapter(spinnerArrayAdapter);
mAddFeedButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
//Hide keyboard
InputMethodManager imm = (InputMethodManager)getSystemService(
Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(mFeedUrlView.getWindowToken(), 0);
attemptAddNewFeed();
}
});
Intent intent = getIntent();
String action = intent.getAction();
@ -101,6 +112,10 @@ public class NewFeedActivity extends AppCompatActivity {
url = intent.getStringExtra(Intent.EXTRA_TEXT);
}
if(url.endsWith(".opml")) {
AsyncTaskHelper.StartAsyncTask(new ImportOpmlSubscriptionsTask(url, NewFeedActivity.this));
}
//String scheme = intent.getScheme();
//ContentResolver resolver = getContentResolver();
@ -110,6 +125,179 @@ public class NewFeedActivity extends AppCompatActivity {
}
}
@OnClick(R.id.btn_addFeed)
public void btnAddFeedClick() {
//Hide keyboard
InputMethodManager imm = (InputMethodManager)getSystemService(
Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(mFeedUrlView.getWindowToken(), 0);
attemptAddNewFeed();
}
@OnClick(R.id.btn_import_opml)
public void importOpml() {
Intent intentFilePicker = new Intent(this, FilePickerActivity.class);
startActivityForResult(intentFilePicker, 1);
}
@OnClick(R.id.btn_export_opml)
public void exportOpml() {
String xml = OpmlXmlParser.GenerateOPML(this);
String path = FileUtils.getPath(this) + "/../subscriptions.opml";
try {
FileOutputStream fos = new FileOutputStream(new File(path));
OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fos);
outputStreamWriter.write(xml);
outputStreamWriter.close();
fos.close();
new AlertDialog.Builder(this)
.setMessage("Successfully exported to: " + path)
.setTitle("OPML Export")
.setNeutralButton("Ok", null)
.create()
.show();
}
catch (IOException e) {
Log.e("Exception", "File write failed: " + e.toString());
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == 1 && resultCode == RESULT_OK) {
String filePath = data.getStringExtra(FilePickerActivity.RESULT_FILE_PATH);
AsyncTaskHelper.StartAsyncTask(new ImportOpmlSubscriptionsTask(filePath, NewFeedActivity.this));
}
}
public static class ImportOpmlSubscriptionsTask extends AsyncTask<Void, Void, Boolean> {
private final String mUrlToFile;
private HashMap<String, String> extractedUrls;
private ProgressDialog pd;
private Context mContext;
ImportOpmlSubscriptionsTask(String urlToFile, Context context) {
this.mUrlToFile = urlToFile;
this.mContext = context;
}
@Override
protected void onPreExecute() {
pd = new ProgressDialog(mContext);
pd.setTitle("Parsing OMPL...");
pd.setMessage("Please wait.");
pd.setCancelable(false);
pd.setIndeterminate(true);
pd.show();
super.onPreExecute();
}
@Override
protected Boolean doInBackground(Void... params) {
String opmlContent;
try {
if(mUrlToFile.startsWith("http")) {//http[s]
opmlContent = URLConnectionReader.getText(mUrlToFile.toString());
} else {
opmlContent = getStringFromFile(mUrlToFile);
}
InputStream is = new ByteArrayInputStream(opmlContent.getBytes());
XmlPullParser parser = Xml.newPullParser();
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
parser.setInput(is, null);
parser.nextTag();
extractedUrls = OpmlXmlParser.ReadFeed(parser);
publishProgress();
API api = new APIv2(HttpJsonRequest.getInstance().getRootUrl());
HashMap<String, Long> existingFolders = new HashMap<>();
InputStream isFolder = HttpJsonRequest.getInstance().PerformJsonRequest(api.getFolderUrl());
String folderJSON = convertStreamToString(isFolder);
JSONArray jArrFolder = new JSONObject(folderJSON).getJSONArray("folders");
for(int i = 0; i < jArrFolder.length(); i++) {
JSONObject folder = ((JSONObject) jArrFolder.get(i));
long folderId = folder.getLong("id");
String folderName = folder.getString("name");
existingFolders.put(folderName, folderId);
}
for(String feedUrl : extractedUrls.keySet()) {
long folderId = 0; //id of the parent folder, 0 for root
String folderName = extractedUrls.get(feedUrl);
if(folderName != null) { //Get Folder ID (create folder if not exists)
if(existingFolders.containsKey(folderName)) { //Check if folder exists
folderId = existingFolders.get(folderName);
} else { //If not, create a new one on the server
Tuple<Integer, String> status = HttpJsonRequest.getInstance().performCreateFolderRequest(api.getFolderUrl(), folderName);
if (status.key == 200 || status.key == 409) { //200 = Ok, 409 = If the folder exists already
JSONObject jObj = new JSONObject(status.value).getJSONArray("folders").getJSONObject(0);
folderId = jObj.getLong("id");
existingFolders.put(folderName, folderId); //Add folder to list of existing folder in order to prevent that the method tries to create it multiple times
} else {
throw new Exception("Failed to create folder on server!");
}
}
}
int status = HttpJsonRequest.getInstance().performCreateFeedRequest(api.getFeedUrl(), feedUrl, folderId);
if(status == 200 || status == 409) {
} else {
throw new Exception("Failed to create feed on server!");
}
}
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
@Override
protected void onProgressUpdate(Void... values) {
String text = "Extracted the following feeds:\n";
for (String url : extractedUrls.keySet()) {
text += "\n" + url;
}
pd.setMessage(text);
super.onProgressUpdate(values);
}
@Override
protected void onPostExecute(Boolean result) {
if (pd != null) {
pd.dismiss();
}
if(!result) {
Toast.makeText(mContext, "Failed to parse OPML file", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(mContext, "Successfully imported OPML!", Toast.LENGTH_LONG).show();
}
super.onPostExecute(result);
}
}
/**
* Attempts to sign in or register the account specified by the login form.
@ -195,8 +383,6 @@ public class NewFeedActivity extends AppCompatActivity {
/**
* Represents an asynchronous login/registration task used to authenticate
* the user.
@ -215,20 +401,14 @@ public class NewFeedActivity extends AppCompatActivity {
@Override
protected Boolean doInBackground(Void... params) {
API api = new APIv2(HttpJsonRequest.getInstance().getRootUrl());
try {
int status = HttpJsonRequest.getInstance().performCreateFeedRequest(api.getFeedUrl(),
mUrlToFeed, mFolderId);
int status = HttpJsonRequest.getInstance().performCreateFeedRequest(api.getFeedUrl(), mUrlToFeed, mFolderId);
if(status == 200) {
return true;
}
Log.d("NewFeedActivity", "Status: " + status);
} catch(Exception ex) {
ex.printStackTrace();
}
return false;
}
@ -271,6 +451,29 @@ public class NewFeedActivity extends AppCompatActivity {
}
return super.onOptionsItemSelected(item);
}
@NonNull public static String convertStreamToString(InputStream is) throws Exception {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
StringBuilder sb = new StringBuilder();
String line = null;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
reader.close();
return sb.toString();
}
public static String getStringFromFile (String filePath) throws Exception {
File fl = new File(filePath);
FileInputStream fin = new FileInputStream(fl);
String ret = convertStreamToString(fin);
//Make sure you close all streams.
fin.close();
return ret;
}
}

View file

@ -0,0 +1,184 @@
package de.luhmer.owncloudnewsreader.helper;
import android.content.Context;
import android.util.Xml;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlSerializer;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.List;
import de.luhmer.owncloudnewsreader.database.DatabaseConnectionOrm;
import de.luhmer.owncloudnewsreader.database.model.Feed;
import de.luhmer.owncloudnewsreader.database.model.Folder;
/**
* Created by David on 14.01.2016.
*/
public class OpmlXmlParser {
//Create XML
public static String GenerateOPML(Context context) {
XmlSerializer serializer = Xml.newSerializer();
StringWriter writer = new StringWriter();
try {
serializer.setOutput(writer);
serializer.startDocument("UTF-8", true);
serializer.startTag("", "opml");
serializer.attribute("", "version", "2.0");
serializer.startTag("", "head");
serializer.startTag("", "title");
serializer.text("Subscriptions");
serializer.endTag("", "title");
serializer.endTag("", "head");
serializer.startTag("", "body");
DatabaseConnectionOrm dbConn = new DatabaseConnectionOrm(context);
List<Folder> folderList = dbConn.getListOfFolders();
List<Feed> feedList = dbConn.getListOfFeeds();
//Process all feeds in folders
for(Folder folder : folderList) {
serializer.startTag("", "outline");
serializer.attribute("", "title", folder.getLabel());
serializer.attribute("", "text", folder.getLabel());
for(Feed feed : folder.getFeedList()) {
feedList.remove(feed);//Remove feed from feedlist (So only feeds without folders will remain)
GenerateXMLForFeed(serializer, feed);
}
serializer.endTag("", "outline");
}
//All feeds without folder
for(Feed feed : feedList) {
GenerateXMLForFeed(serializer, feed);
}
serializer.endTag("", "body");
serializer.endTag("", "opml");
serializer.endDocument();
return writer.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static void GenerateXMLForFeed(XmlSerializer serializer, Feed feed) throws IOException {
serializer.startTag("", "outline");
serializer.attribute("", "title", feed.getFeedTitle());
serializer.attribute("", "text", feed.getFeedTitle());
serializer.attribute("", "type", "rss");
serializer.attribute("", "xmlUrl", feed.getLink());
//serializer.attribute("", "htmlUrl", key);
serializer.endTag("", "outline");
}
//Parse XML
// We don't use namespaces
private static final String ns = null;
public static HashMap<String, String> ReadFeed(XmlPullParser parser) throws XmlPullParserException, IOException {
HashMap<String, String> extractedUrls = new HashMap<>();
parser.require(XmlPullParser.START_TAG, ns, "opml");
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.getEventType() != XmlPullParser.START_TAG) {
continue;
}
String name = parser.getName();
// Starts by looking for the entry tag
if (name.equals("body")) {
extractedUrls.putAll(readFolder(parser));
} else {
Skip(parser);
}
}
return extractedUrls;
}
private static class Entry {
public Entry(String folderName, String feedUrl) {
this.feedUrl = feedUrl;
this.folderName = folderName;
}
public String folderName;
public String feedUrl;
}
private static HashMap<String, String> readFolder(XmlPullParser parser) throws XmlPullParserException, IOException {
HashMap<String, String> extractedUrls = new HashMap<>();
String name;
String folderName = null;
parser.require(XmlPullParser.START_TAG, ns, "body");
while(parser.next() >= 0) { //Loop over all
if(parser.getEventType() == XmlPullParser.END_TAG) { //If read endtag and folder Name is != null
if(folderName == null) { //If end tag is read and we aren't exiting a folder --> exit!
break;
}
folderName = null;
}
if (parser.getEventType() != XmlPullParser.START_TAG) {
continue;
}
name = parser.getName();
if (name.equals("outline")) {
Entry entry = ReadOutline(parser);
if (entry.folderName != null) {
folderName = entry.folderName;
} else {
entry.folderName = folderName;
extractedUrls.put(entry.feedUrl, entry.folderName);
parser.next(); //Read closing tag
}
}
}
return extractedUrls;
}
// Parses the contents of an entry. If it encounters a title, summary, or link tag, hands them off
// to their respective "read" methods for processing. Otherwise, skips the tag.
private static Entry ReadOutline(XmlPullParser parser) throws XmlPullParserException, IOException {
//parser.require(XmlPullParser.START_TAG, ns, "outline");
String link = parser.getAttributeValue(null, "xmlUrl");
String title = null;
if(link == null) { //Parse folder title if no feedUrl is available
title = parser.getAttributeValue(null, "title");
}
return new Entry(title, link);
}
private static void Skip(XmlPullParser parser) throws XmlPullParserException, IOException {
if (parser.getEventType() != XmlPullParser.START_TAG) {
throw new IllegalStateException();
}
int depth = 1;
while (depth != 0) {
switch (parser.next()) {
case XmlPullParser.END_TAG:
depth--;
break;
case XmlPullParser.START_TAG:
depth++;
break;
}
}
}
}

View file

@ -0,0 +1,29 @@
package de.luhmer.owncloudnewsreader.helper;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
/**
* Created by David on 13.01.2016.
*/
public class URLConnectionReader {
public static String getText(String url) throws Exception {
URL website = new URL(url);
URLConnection connection = website.openConnection();
BufferedReader in = new BufferedReader(
new InputStreamReader(
connection.getInputStream()));
StringBuilder response = new StringBuilder();
String inputLine;
while ((inputLine = in.readLine()) != null)
response.append(inputLine);
in.close();
return response.toString();
}
}

View file

@ -47,6 +47,7 @@ import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import de.luhmer.owncloudnewsreader.SettingsActivity;
import de.luhmer.owncloudnewsreader.model.Tuple;
import de.luhmer.owncloudnewsreader.ssl.MemorizingTrustManager;
import de.luhmer.owncloudnewsreader.ssl.TLSSocketFactory;
@ -231,4 +232,15 @@ public class HttpJsonRequest {
return response.code();
}
public Tuple<Integer, String> performCreateFolderRequest(HttpUrl url, String folderName) throws Exception {
Request request = new Request.Builder()
.url(url)
.post(RequestBody.create(JSON, new JSONObject().put("name", folderName).toString()))
.build();
Response response = client.newCall(request).execute();
String body = response.body().string();
return new Tuple<>(response.code(), body);
}
}

View file

@ -64,6 +64,32 @@
android:text="@string/action_add_feed"
android:textStyle="bold"/>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
style="?android:textAppearanceSmall"
android:id="@+id/btn_import_opml"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Import OPML"
android:textStyle="bold" />
<Button
style="?android:textAppearanceSmall"
android:id="@+id/btn_export_opml"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Export OPML"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</ScrollView>