Add initial support for feeds and folders export in OPML file

This commit is contained in:
Shinokuni 2019-11-08 18:26:38 +01:00
parent 1daf6e0733
commit 198e9ae299
14 changed files with 256 additions and 101 deletions

View File

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:name=".utils.ReadropsApp"
@ -16,7 +18,8 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
android:requestLegacyExternalStorage="true"
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
<activity
android:name=".activities.WebViewActivity"
android:theme="@style/AppTheme.NoActionBar" />

View File

@ -1,12 +1,18 @@
package com.readrops.app.fragments.settings;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.preference.Preference;
@ -18,8 +24,15 @@ import com.readrops.app.activities.AddAccountActivity;
import com.readrops.app.activities.ManageFeedsFoldersActivity;
import com.readrops.app.database.entities.account.Account;
import com.readrops.app.database.entities.account.AccountType;
import com.readrops.app.utils.OPMLMatcher;
import com.readrops.app.viewmodels.AccountViewModel;
import com.readrops.readropslibrary.opml.OpmlParser;
import com.readrops.readropslibrary.opml.OPMLParser;
import com.readrops.readropslibrary.opml.model.OPML;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.observers.DisposableCompletableObserver;
@ -34,7 +47,10 @@ import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
*/
public class AccountSettingsFragment extends PreferenceFragmentCompat {
public static final int OPEN_OPML_FILE_REQUEST = 1;
private static final String TAG = AccountSettingsFragment.class.getSimpleName();
private static final int OPEN_OPML_FILE_REQUEST = 1;
private static final int WRITE_EXTERNAL_STORAGE_REQUEST = 1;
private Account account;
private AccountViewModel viewModel;
@ -94,7 +110,19 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
});
opmlPref.setOnPreferenceClickListener(preference -> {
new MaterialDialog.Builder(getActivity())
.items(R.array.opml_import_export)
.itemsCallback(((dialog, itemView, position, text) -> {
if (position == 0) {
openOPMLFile();
} else {
if (isExternalStoragePermissionGranted())
exportAsOPMLFile();
else
requestExternalStoragePermission();
}
}))
.show();
return true;
});
}
@ -137,6 +165,55 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
startActivityForResult(intent, OPEN_OPML_FILE_REQUEST);
}
private void exportAsOPMLFile() {
try {
String filePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath();
File file = new File(filePath, "subscriptions.opml");
final OutputStream outputStream = new FileOutputStream(file);
viewModel.getFoldersWithFeeds()
.flatMapCompletable(folderListMap -> {
OPML opml = OPMLMatcher.INSTANCE.foldersAndFeedsToOPML(folderListMap, getContext());
return OPMLParser.write(opml, outputStream);
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doAfterTerminate(() -> {
try {
outputStream.flush();
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
})
.subscribe(new DisposableCompletableObserver() {
@Override
public void onComplete() {
Log.d(TAG, "onComplete: ");
}
@Override
public void onError(Throwable e) {
Log.d(TAG, "onError: ");
}
});
} catch (Exception e) {
Log.d(TAG, e.getMessage());
}
}
private boolean isExternalStoragePermissionGranted() {
return ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
}
private void requestExternalStoragePermission() {
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE}, WRITE_EXTERNAL_STORAGE_REQUEST);
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == OPEN_OPML_FILE_REQUEST && resultCode == RESULT_OK && data != null) {
@ -155,8 +232,23 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
super.onActivityResult(requestCode, resultCode, data);
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST) {
if (grantResults[0] != PackageManager.PERMISSION_GRANTED) {
//if (shouldShowRequestPermissionRationale(permissions[0]))
} else {
exportAsOPMLFile();
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
private void parseOPMLFile(Uri uri, MaterialDialog dialog) {
OpmlParser.parse(uri, getContext())
OPMLParser.parse(uri, getContext())
.flatMapCompletable(opml -> viewModel.insertOPMLFoldersAndFeeds(opml))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())

View File

@ -20,6 +20,7 @@ import com.readrops.app.utils.feedscolors.FeedsColorsIntentService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import io.reactivex.Completable;
import io.reactivex.Observable;
@ -118,6 +119,35 @@ public abstract class ARepository<T> {
return database.feedDao().getFeedCount(accountId);
}
public Single<Map<Folder, List<Feed>>> getFoldersWithFeeds() {
return Single.create(emitter -> {
List<Folder> folders = database.folderDao().getFolders(account.getId());
Map<Folder, List<Feed>> foldersWithFeeds = new TreeMap<>(Folder::compareTo);
for (Folder folder : folders) {
List<Feed> feeds = database.feedDao().getFeedsByFolder(folder.getId());
for (Feed feed : feeds) {
int unreadCount = database.itemDao().getUnreadCount(feed.getId());
feed.setUnreadCount(unreadCount);
}
foldersWithFeeds.put(folder, feeds);
}
Folder noFolder = new Folder("no folder");
List<Feed> feedsWithoutFolder = database.feedDao().getFeedsWithoutFolder(account.getId());
for (Feed feed : feedsWithoutFolder) {
feed.setUnreadCount(database.itemDao().getUnreadCount(feed.getId()));
}
foldersWithFeeds.put(noFolder, feedsWithoutFolder);
emitter.onSuccess(foldersWithFeeds);
});
}
protected void setFeedColors(Feed feed) {
FeedColorsKt.setFeedColors(feed);
database.feedDao().updateColors(feed.getId(),

View File

@ -1,25 +1,16 @@
package com.readrops.app.utils;
import com.readrops.app.database.entities.Feed;
import com.readrops.app.database.entities.Folder;
import com.readrops.app.database.entities.account.Account;
import com.readrops.readropslibrary.localfeed.atom.ATOMFeed;
import com.readrops.readropslibrary.localfeed.json.JSONFeed;
import com.readrops.readropslibrary.localfeed.rss.RSSChannel;
import com.readrops.readropslibrary.localfeed.rss.RSSFeed;
import com.readrops.readropslibrary.opml.model.Body;
import com.readrops.readropslibrary.opml.model.Opml;
import com.readrops.readropslibrary.opml.model.Outline;
import com.readrops.readropslibrary.services.freshrss.json.FreshRSSFeed;
import com.readrops.readropslibrary.services.nextcloudnews.json.NextNewsFeed;
import org.jsoup.Jsoup;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class FeedMatcher {
public static Feed nextNewsFeedToFeed(NextNewsFeed feed, Account account) {
@ -109,27 +100,4 @@ public final class FeedMatcher {
return feed;
}
public static Map<Folder, List<Feed>> feedsAndFoldersFromOPML(Opml opml) {
Map<Folder, List<Feed>> foldersAndFeeds = new HashMap<>();
Body body = opml.getBody();
for (Outline outline : body.getOutlines()) {
Folder folder = new Folder(outline.getTitle());
List<Feed> feeds = new ArrayList<>();
for (Outline feedOutline : outline.getOutlines()) {
Feed feed = new Feed();
feed.setName(feedOutline.getTitle());
feed.setUrl(feedOutline.getXmlUrl());
feed.setSiteUrl(feedOutline.getHtmlUrl());
feeds.add(feed);
}
foldersAndFeeds.put(folder, feeds);
}
return foldersAndFeeds;
}
}

View File

@ -0,0 +1,62 @@
package com.readrops.app.utils
import android.content.Context
import com.readrops.app.R
import com.readrops.app.database.entities.Feed
import com.readrops.app.database.entities.Folder
import com.readrops.readropslibrary.opml.model.Body
import com.readrops.readropslibrary.opml.model.Head
import com.readrops.readropslibrary.opml.model.OPML
import com.readrops.readropslibrary.opml.model.Outline
object OPMLMatcher {
fun opmltoFoldersAndFeeds(opml: OPML): Map<Folder, List<Feed>> {
val foldersAndFeeds: MutableMap<Folder, List<Feed>> = HashMap()
val body = opml.body!!
body.outlines?.forEach { outline ->
val folder = Folder(outline.title)
val feeds = arrayListOf<Feed>()
outline.outlines?.forEach { feedOutline ->
val feed = Feed().let {
it.name = feedOutline.title
it.url = feedOutline.xmlUrl
it.siteUrl = feedOutline.htmlUrl
it
}
feeds.add(feed)
}
foldersAndFeeds[folder] = feeds
}
return foldersAndFeeds
}
fun foldersAndFeedsToOPML(foldersAndFeeds: Map<Folder, List<Feed>>, context: Context): OPML {
val outlines = arrayListOf<Outline>()
for (folderAndFeeds in foldersAndFeeds) {
val outline = Outline(folderAndFeeds.key.name)
val feedOutlines = arrayListOf<Outline>()
folderAndFeeds.value.forEach { feed ->
val feedOutline = Outline(feed.name, feed.url, feed.siteUrl)
feedOutlines.add(feedOutline)
}
outline.outlines = feedOutlines
outlines.add(outline)
}
val head = Head(context.getString(R.string.subscriptions))
val body = Body(outlines)
return OPML("2.0", head, body)
}
}

View File

@ -12,8 +12,8 @@ import com.readrops.app.database.entities.Folder;
import com.readrops.app.database.entities.account.Account;
import com.readrops.app.database.entities.account.AccountType;
import com.readrops.app.repositories.ARepository;
import com.readrops.app.utils.FeedMatcher;
import com.readrops.readropslibrary.opml.model.Opml;
import com.readrops.app.utils.OPMLMatcher;
import com.readrops.readropslibrary.opml.model.OPML;
import java.util.List;
import java.util.Map;
@ -66,9 +66,13 @@ public class AccountViewModel extends AndroidViewModel {
return database.accountDao().getAccountCount();
}
public Completable insertOPMLFoldersAndFeeds(Opml opml) {
Map<Folder, List<Feed>> foldersAndFeeds = FeedMatcher.feedsAndFoldersFromOPML(opml);
public Completable insertOPMLFoldersAndFeeds(OPML opml) {
Map<Folder, List<Feed>> foldersAndFeeds = OPMLMatcher.INSTANCE.opmltoFoldersAndFeeds(opml);
return repository.insertOPMLFoldersAndFeeds(foldersAndFeeds);
}
public Single<Map<Folder, List<Feed>>> getFoldersWithFeeds() {
return repository.getFoldersWithFeeds();
}
}

View File

@ -13,9 +13,9 @@ import com.readrops.app.activities.MainActivity;
import com.readrops.app.database.Database;
import com.readrops.app.database.ItemsListQueryBuilder;
import com.readrops.app.database.RoomFactoryWrapper;
import com.readrops.app.database.entities.account.Account;
import com.readrops.app.database.entities.Feed;
import com.readrops.app.database.entities.Folder;
import com.readrops.app.database.entities.account.Account;
import com.readrops.app.database.pojo.ItemWithFeed;
import com.readrops.app.repositories.ARepository;
@ -23,7 +23,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import io.reactivex.Completable;
import io.reactivex.Observable;
@ -125,32 +124,7 @@ public class MainViewModel extends AndroidViewModel {
}
public Single<Map<Folder, List<Feed>>> getFoldersWithFeeds() {
return Single.create(emitter -> {
List<Folder> folders = db.folderDao().getFolders(currentAccount.getId());
Map<Folder, List<Feed>> foldersWithFeeds = new TreeMap<>(Folder::compareTo);
for (Folder folder : folders) {
List<Feed> feeds = db.feedDao().getFeedsByFolder(folder.getId());
for (Feed feed : feeds) {
int unreadCount = db.itemDao().getUnreadCount(feed.getId());
feed.setUnreadCount(unreadCount);
}
foldersWithFeeds.put(folder, feeds);
}
Folder noFolder = new Folder("no folder");
List<Feed> feedsWithoutFolder = db.feedDao().getFeedsWithoutFolder(currentAccount.getId());
for (Feed feed : feedsWithoutFolder) {
feed.setUnreadCount(db.itemDao().getUnreadCount(feed.getId()));
}
foldersWithFeeds.put(noFolder, feedsWithoutFolder);
emitter.onSuccess(foldersWithFeeds);
});
return repository.getFoldersWithFeeds();
}
//endregion

View File

@ -97,5 +97,7 @@
<string name="opml_processing">Traitement du fichier OPML</string>
<string name="operation_takes_time">Cette opération peut prendre un certain temps car il faut interroger chaque flux.</string>
<string name="processing_file_failed">Une erreur s\'est produite lors du traitement du fichier</string>
<string name="import_opml">Importer un fichier OPML</string>
<string name="export_feeds_folders_opml">Exporter les flux et les dossiers dans un fichier OPML</string>
</resources>

View File

@ -5,6 +5,11 @@
<item>@string/filter_oldest</item>
</string-array>
<string-array name="opml_import_export">
<item>@string/import_opml</item>
<item>@string/export_feeds_folders_opml</item>
</string-array>
<string-array name="items_per_feed_numbers_values">
<item>20</item>
<item>50</item>

View File

@ -105,4 +105,7 @@
<string name="opml_processing">Processing OPML file</string>
<string name="operation_takes_time">This operation can take a significant time as each feed needs to be queried.</string>
<string name="processing_file_failed">An error occurred during the file processing</string>
<string name="import_opml">Import OPML file</string>
<string name="export_feeds_folders_opml">Export feeds and folders to an OPML file</string>
<string name="subscriptions" translatable="false">Subscriptions</string>
</resources>

View File

@ -0,0 +1,40 @@
package com.readrops.readropslibrary.opml
import android.content.Context
import android.net.Uri
import com.readrops.readropslibrary.opml.model.OPML
import com.readrops.readropslibrary.utils.LibUtils
import io.reactivex.Completable
import io.reactivex.Single
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.core.Persister
import java.io.OutputStream
class OPMLParser {
companion object {
@JvmStatic
fun parse(uri: Uri, context: Context): Single<OPML> {
return Single.create { emitter ->
val fileString = LibUtils.fileToString(uri, context)
val serializer: Serializer = Persister()
val opml: OPML = serializer.read(OPML::class.java, fileString)
emitter.onSuccess(opml)
}
}
@JvmStatic
fun write(opml: OPML, outputStream: OutputStream): Completable {
return Completable.create { emitter ->
val serializer: Serializer = Persister()
serializer.write(opml, outputStream)
emitter.onComplete()
}
}
}
}

View File

@ -1,28 +0,0 @@
package com.readrops.readropslibrary.opml
import android.content.Context
import android.net.Uri
import com.readrops.readropslibrary.opml.model.Opml
import com.readrops.readropslibrary.utils.LibUtils
import io.reactivex.Single
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.core.Persister
class OpmlParser {
companion object {
@JvmStatic
fun parse(uri: Uri, context: Context): Single<Opml> {
return Single.create { emitter ->
val fileString = LibUtils.fileToString(uri, context)
val serializer: Serializer = Persister()
val opml: Opml = serializer.read(Opml::class.java, fileString)
emitter.onSuccess(opml)
}
}
}
}

View File

@ -5,7 +5,7 @@ import org.simpleframework.xml.Element
import org.simpleframework.xml.Root
@Root(name = "opml", strict = false)
data class Opml(@field:Attribute(required = true) var version: String?,
data class OPML(@field:Attribute(required = true) var version: String?,
@field:Element(required = true) var head: Head?,
@field:Element(required = true) var body: Body?) {

View File

@ -20,7 +20,7 @@ data class Outline(@field:Attribute(required = false) var title: String?,
null,
null)
fun hasSubElements(): Boolean {
return !outlines.isNullOrEmpty()
}
constructor(title: String) : this(title, null, null, null, null, null)
constructor(title: String, xmlUrl: String, htmlUrl: String) : this(title, null, null, xmlUrl, htmlUrl, null)
}