Merge pull request #1145 from mauriciocolli/dev
Implement subscriptions import/export
|
@ -72,7 +72,7 @@ dependencies {
|
|||
implementation 'com.nostra13.universalimageloader:universal-image-loader:1.9.5'
|
||||
implementation 'de.hdodenhof:circleimageview:2.2.0'
|
||||
implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1'
|
||||
implementation 'com.nononsenseapps:filepicker:3.0.1'
|
||||
implementation 'com.nononsenseapps:filepicker:4.2.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.7.0'
|
||||
|
||||
debugImplementation 'com.facebook.stetho:stetho:1.5.0'
|
||||
|
|
|
@ -70,6 +70,9 @@
|
|||
android:name=".history.HistoryActivity"
|
||||
android:label="@string/title_activity_history"/>
|
||||
|
||||
<service android:name=".subscription.services.SubscriptionsImportService"/>
|
||||
<service android:name=".subscription.services.SubscriptionsExportService"/>
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
android:launchMode="singleInstance"
|
||||
|
@ -117,7 +120,7 @@
|
|||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"/>
|
||||
android:resource="@xml/nnf_provider_paths"/>
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
|
|
|
@ -89,7 +89,6 @@ public class App extends Application {
|
|||
SettingsActivity.initSettings(this);
|
||||
|
||||
NewPipe.init(getDownloader());
|
||||
NewPipeDatabase.init(this);
|
||||
StateSaver.init(this);
|
||||
initNotificationChannel();
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
package org.schabi.newpipe;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.AttrRes;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
|
@ -86,6 +84,17 @@ public abstract class BaseFragment extends Fragment {
|
|||
protected void initListeners() {
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public void setTitle(String title) {
|
||||
if (DEBUG) Log.d(TAG, "setTitle() called with: title = [" + title + "]");
|
||||
if (activity != null && activity.getSupportActionBar() != null) {
|
||||
activity.getSupportActionBar().setTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// DisplayImageOptions default configurations
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
|
|
@ -11,31 +11,32 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12;
|
|||
|
||||
public final class NewPipeDatabase {
|
||||
|
||||
private static AppDatabase databaseInstance;
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
private NewPipeDatabase() {
|
||||
//no instance
|
||||
}
|
||||
|
||||
public static void init(Context context) {
|
||||
databaseInstance = Room
|
||||
.databaseBuilder(context, AppDatabase.class, DATABASE_NAME)
|
||||
private static AppDatabase getDatabase(Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_11_12)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Deprecated
|
||||
public static AppDatabase getInstance() {
|
||||
if (databaseInstance == null) throw new RuntimeException("Database not initialized");
|
||||
public static AppDatabase getInstance(@NonNull Context context) {
|
||||
AppDatabase result = databaseInstance;
|
||||
if (result == null) {
|
||||
synchronized (NewPipeDatabase.class) {
|
||||
result = databaseInstance;
|
||||
if (result == null) {
|
||||
databaseInstance = (result = getDatabase(context));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return databaseInstance;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static AppDatabase getInstance(Context context) {
|
||||
if (databaseInstance == null) init(context);
|
||||
return databaseInstance;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import android.arch.persistence.room.Dao;
|
||||
import android.arch.persistence.room.Insert;
|
||||
import android.arch.persistence.room.OnConflictStrategy;
|
||||
import android.arch.persistence.room.Query;
|
||||
import android.arch.persistence.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
|
||||
|
@ -11,24 +14,56 @@ import io.reactivex.Flowable;
|
|||
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
|
||||
|
||||
@Dao
|
||||
public interface SubscriptionDAO extends BasicDAO<SubscriptionEntity> {
|
||||
public abstract class SubscriptionDAO implements BasicDAO<SubscriptionEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
|
||||
Flowable<List<SubscriptionEntity>> getAll();
|
||||
public abstract Flowable<List<SubscriptionEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + SUBSCRIPTION_TABLE)
|
||||
int deleteAll();
|
||||
public abstract int deleteAll();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<SubscriptionEntity>> listByService(int serviceId);
|
||||
public abstract Flowable<List<SubscriptionEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
|
||||
public abstract Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
|
||||
|
||||
@Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
||||
abstract Long getSubscriptionIdInternal(int serviceId, String url);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract Long insertInternal(final SubscriptionEntity entities);
|
||||
|
||||
@Transaction
|
||||
public List<SubscriptionEntity> upsertAll(List<SubscriptionEntity> entities) {
|
||||
for (SubscriptionEntity entity : entities) {
|
||||
Long uid = insertInternal(entity);
|
||||
|
||||
if (uid != -1) {
|
||||
entity.setUid(uid);
|
||||
continue;
|
||||
}
|
||||
|
||||
uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl());
|
||||
entity.setUid(uid);
|
||||
|
||||
if (uid == -1) {
|
||||
throw new IllegalStateException("Invalid subscription id (-1)");
|
||||
}
|
||||
|
||||
update(entity);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@ import android.arch.persistence.room.Entity;
|
|||
import android.arch.persistence.room.Ignore;
|
||||
import android.arch.persistence.room.Index;
|
||||
import android.arch.persistence.room.PrimaryKey;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
|
||||
|
@ -17,6 +19,7 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR
|
|||
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
||||
public class SubscriptionEntity {
|
||||
|
||||
final static String SUBSCRIPTION_UID = "uid";
|
||||
final static String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
final static String SUBSCRIPTION_URL = "url";
|
||||
|
@ -116,9 +119,18 @@ public class SubscriptionEntity {
|
|||
@Ignore
|
||||
public ChannelInfoItem toChannelInfoItem() {
|
||||
ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
|
||||
item.thumbnail_url = getAvatarUrl();
|
||||
item.subscriber_count = getSubscriberCount();
|
||||
item.description = getDescription();
|
||||
item.setThumbnailUrl(getAvatarUrl());
|
||||
item.setSubscriberCount(getSubscriberCount());
|
||||
item.setDescription(getDescription());
|
||||
return item;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull ChannelInfo info) {
|
||||
SubscriptionEntity result = new SubscriptionEntity();
|
||||
result.setServiceId(info.getServiceId());
|
||||
result.setUrl(info.getUrl());
|
||||
result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -246,13 +246,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
|||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public void setTitle(String title) {
|
||||
if (DEBUG) Log.d(TAG, "setTitle() called with: title = [" + title + "]");
|
||||
if (activity != null && activity.getSupportActionBar() != null) {
|
||||
activity.getSupportActionBar().setTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
protected void openUrlInBrowser(String url) {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
startActivity(Intent.createChooser(intent, activity.getString(R.string.share_dialog_title)));
|
||||
|
|
|
@ -106,7 +106,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
super.onCreateOptionsMenu(menu, inflater);
|
||||
if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
inflater.inflate(R.menu.main_fragment_menu, menu);
|
||||
SubMenu kioskMenu = menu.addSubMenu(getString(R.string.kiosk));
|
||||
SubMenu kioskMenu = menu.addSubMenu(Menu.NONE, Menu.NONE, 200, getString(R.string.kiosk));
|
||||
try {
|
||||
createKioskMenu(kioskMenu, inflater);
|
||||
} catch (Exception e) {
|
||||
|
|
|
@ -141,8 +141,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void selected(StreamInfoItem selectedItem) {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openVideoDetailFragment(
|
||||
useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(),
|
||||
NavigationHelper.openVideoDetailFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(),
|
||||
selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName());
|
||||
}
|
||||
|
||||
|
@ -156,8 +155,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void selected(ChannelInfoItem selectedItem) {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openChannelFragment(
|
||||
useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(),
|
||||
NavigationHelper.openChannelFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(),
|
||||
selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName());
|
||||
}
|
||||
});
|
||||
|
@ -166,8 +164,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void selected(PlaylistInfoItem selectedItem) {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openPlaylistFragment(
|
||||
useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(),
|
||||
NavigationHelper.openPlaylistFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(),
|
||||
selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName());
|
||||
}
|
||||
});
|
||||
|
@ -230,7 +227,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||
if(useAsFrontPage) {
|
||||
if (useAsFrontPage) {
|
||||
supportActionBar.setDisplayHomeAsUpEnabled(false);
|
||||
} else {
|
||||
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
||||
|
@ -277,9 +274,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
|
||||
@Override
|
||||
public void showListFooter(final boolean show) {
|
||||
itemsList.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
itemsList.post(() -> {
|
||||
if (infoListAdapter != null && itemsList != null) {
|
||||
infoListAdapter.showFooter(show);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -33,12 +33,12 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
|||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.fragments.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.playlist.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.playlist.PlayQueue;
|
||||
import org.schabi.newpipe.playlist.SinglePlayQueue;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.util.AnimationUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
@ -108,11 +108,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
subscriptionService = SubscriptionService.getInstance();
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_channel, container, false);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,8 +21,8 @@ import org.schabi.newpipe.extractor.NewPipe;
|
|||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.fragments.list.BaseListFragment;
|
||||
import org.schabi.newpipe.fragments.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.SubscriptionService;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
|
@ -64,7 +64,7 @@ public class FeedFragment extends BaseListFragment<List<SubscriptionEntity>, Voi
|
|||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
subscriptionService = SubscriptionService.getInstance();
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
|
||||
FEED_LOAD_COUNT = howManyItemsToLoad();
|
||||
}
|
||||
|
|
|
@ -27,8 +27,6 @@ import java.util.ArrayList;
|
|||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
|
||||
|
@ -147,7 +145,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
|||
|
||||
private void onPlaylistSelected(@NonNull LocalPlaylistManager manager,
|
||||
@NonNull PlaylistMetadataEntry playlist,
|
||||
@Nonnull List<StreamEntity> streams) {
|
||||
@NonNull List<StreamEntity> streams) {
|
||||
if (getStreams() == null) return;
|
||||
|
||||
@SuppressLint("ShowToast")
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package org.schabi.newpipe.fragments.subscription;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.DialogFragment;
|
||||
import android.support.v4.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
|
||||
public class ImportConfirmationDialog extends DialogFragment {
|
||||
@State
|
||||
protected Intent resultServiceIntent;
|
||||
|
||||
public void setResultServiceIntent(Intent resultServiceIntent) {
|
||||
this.resultServiceIntent = resultServiceIntent;
|
||||
}
|
||||
|
||||
public static void show(@NonNull Fragment fragment, @NonNull Intent resultServiceIntent) {
|
||||
if (fragment.getFragmentManager() == null) return;
|
||||
|
||||
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
|
||||
confirmationDialog.setResultServiceIntent(resultServiceIntent);
|
||||
confirmationDialog.show(fragment.getFragmentManager(), null);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
return new AlertDialog.Builder(getContext(), ThemeHelper.getDialogTheme(getContext()))
|
||||
.setMessage(R.string.import_network_expensive_warning)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
|
||||
if (resultServiceIntent != null && getContext() != null) {
|
||||
getContext().startService(resultServiceIntent);
|
||||
}
|
||||
dismiss();
|
||||
})
|
||||
.create();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (resultServiceIntent == null) throw new IllegalStateException("Result intent is null");
|
||||
|
||||
Icepick.restoreInstanceState(this, savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
Icepick.saveInstanceState(this, outState);
|
||||
}
|
||||
}
|
|
@ -1,30 +1,62 @@
|
|||
package org.schabi.newpipe.fragments.subscription;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.DrawableRes;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.support.v7.app.ActionBar;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.subscription.services.SubscriptionsExportService;
|
||||
import org.schabi.newpipe.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.CollapsibleView;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.Observer;
|
||||
|
@ -33,18 +65,29 @@ import io.reactivex.disposables.CompositeDisposable;
|
|||
import io.reactivex.disposables.Disposable;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
|
||||
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
||||
|
||||
public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEntity>> {
|
||||
private View headerRootLayout;
|
||||
private static final int REQUEST_EXPORT_CODE = 666;
|
||||
private static final int REQUEST_IMPORT_CODE = 667;
|
||||
|
||||
private InfoListAdapter infoListAdapter;
|
||||
private RecyclerView itemsList;
|
||||
|
||||
@State
|
||||
protected Parcelable itemsListState;
|
||||
private InfoListAdapter infoListAdapter;
|
||||
|
||||
private View headerRootLayout;
|
||||
private View whatsNewItemListHeader;
|
||||
private View importExportListHeader;
|
||||
|
||||
@State
|
||||
protected Parcelable importExportOptionsState;
|
||||
private CollapsibleView importExportOptions;
|
||||
|
||||
/* Used for independent events */
|
||||
private CompositeDisposable disposables = new CompositeDisposable();
|
||||
private SubscriptionService subscriptionService;
|
||||
|
||||
|
@ -52,39 +95,48 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
// Fragment LifeCycle
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
||||
super.setUserVisibleHint(isVisibleToUser);
|
||||
if(isVisibleToUser && activity != null) {
|
||||
activity.getSupportActionBar()
|
||||
.setTitle(R.string.tab_subscriptions);
|
||||
if (isVisibleToUser) {
|
||||
setTitle(getString(R.string.tab_subscriptions));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
infoListAdapter = new InfoListAdapter(activity);
|
||||
subscriptionService = SubscriptionService.getInstance();
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||
activity.getSupportActionBar().setDisplayShowTitleEnabled(true);
|
||||
activity.setTitle(R.string.tab_subscriptions);
|
||||
if(useAsFrontPage) {
|
||||
activity.getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
}
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_subscription, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setupBroadcastReceiver();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
||||
importExportOptionsState = importExportOptions.onSaveInstanceState();
|
||||
|
||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -103,9 +155,131 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
super.onDestroy();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
/*/////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
/////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||
setTitle(getString(R.string.tab_subscriptions));
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Subscriptions import/export
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private BroadcastReceiver subscriptionBroadcastReceiver;
|
||||
|
||||
private void setupBroadcastReceiver() {
|
||||
if (activity == null) return;
|
||||
|
||||
if (subscriptionBroadcastReceiver != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
||||
}
|
||||
|
||||
final IntentFilter filters = new IntentFilter();
|
||||
filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
|
||||
filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
|
||||
subscriptionBroadcastReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (importExportOptions != null) importExportOptions.collapse();
|
||||
}
|
||||
};
|
||||
|
||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
|
||||
}
|
||||
|
||||
private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
|
||||
final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
|
||||
final TextView titleView = itemRoot.findViewById(android.R.id.text1);
|
||||
final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
|
||||
|
||||
titleView.setText(title);
|
||||
iconView.setImageResource(icon);
|
||||
|
||||
container.addView(itemRoot);
|
||||
return itemRoot;
|
||||
}
|
||||
|
||||
private void setupImportFromItems(final ViewGroup listHolder) {
|
||||
final View previousBackupItem = addItemView(getString(R.string.previous_export), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
|
||||
previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
|
||||
|
||||
final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
|
||||
final String[] services = getResources().getStringArray(R.array.service_list);
|
||||
for (String serviceName : services) {
|
||||
try {
|
||||
final StreamingService service = NewPipe.getService(serviceName);
|
||||
|
||||
final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
|
||||
if (subscriptionExtractor == null) continue;
|
||||
|
||||
final List<SubscriptionExtractor.ContentSource> supportedSources = subscriptionExtractor.getSupportedSources();
|
||||
if (supportedSources.isEmpty()) continue;
|
||||
|
||||
final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
|
||||
final ImageView iconView = itemView.findViewById(android.R.id.icon1);
|
||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
|
||||
|
||||
itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
|
||||
} catch (ExtractionException e) {
|
||||
throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setupExportToItems(final ViewGroup listHolder) {
|
||||
final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
|
||||
previousBackupItem.setOnClickListener(item -> onExportSelected());
|
||||
}
|
||||
|
||||
private void onImportFromServiceSelected(int serviceId) {
|
||||
if (getParentFragment() == null) return;
|
||||
NavigationHelper.openSubscriptionsImportFragment(getParentFragment().getFragmentManager(), serviceId);
|
||||
}
|
||||
|
||||
private void onImportPreviousSelected() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
|
||||
}
|
||||
|
||||
private void onExportSelected() {
|
||||
final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
|
||||
final String exportName = "newpipe_subscriptions_" + date + ".json";
|
||||
final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
|
||||
|
||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
|
||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||
final File exportFile = Utils.getFileForUri(data.getData());
|
||||
if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
|
||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
activity.startService(new Intent(activity, SubscriptionsExportService.class)
|
||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
|
||||
}
|
||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
/*/////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Views
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||
|
@ -116,9 +290,27 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
itemsList.setLayoutManager(new LinearLayoutManager(activity));
|
||||
|
||||
infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
|
||||
infoListAdapter.useMiniItemVariants(true);
|
||||
whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
|
||||
importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
|
||||
importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
|
||||
|
||||
infoListAdapter.useMiniItemVariants(true);
|
||||
itemsList.setAdapter(infoListAdapter);
|
||||
|
||||
setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
|
||||
setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
|
||||
|
||||
if (importExportOptionsState != null) {
|
||||
importExportOptions.onRestoreInstanceState(importExportOptionsState);
|
||||
importExportOptionsState = null;
|
||||
}
|
||||
|
||||
importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
|
||||
importExportOptions.ready();
|
||||
}
|
||||
|
||||
private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
|
||||
return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -130,12 +322,14 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
public void selected(ChannelInfoItem selectedItem) {
|
||||
// Requires the parent fragment to find holder for fragment replacement
|
||||
NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(),
|
||||
selectedItem.getServiceId(), selectedItem.url, selectedItem.getName());
|
||||
selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName());
|
||||
}
|
||||
});
|
||||
|
||||
headerRootLayout.setOnClickListener(view ->
|
||||
//noinspection ConstantConditions
|
||||
whatsNewItemListHeader.setOnClickListener(v ->
|
||||
NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager()));
|
||||
importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
|
||||
}
|
||||
|
||||
private void resetFragment() {
|
||||
|
@ -189,6 +383,7 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
infoListAdapter.clearStreamItemList();
|
||||
|
||||
if (result.isEmpty()) {
|
||||
whatsNewItemListHeader.setVisibility(View.GONE);
|
||||
showEmptyState();
|
||||
} else {
|
||||
infoListAdapter.addInfoItemList(getSubscriptionItems(result));
|
||||
|
@ -196,7 +391,7 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
||||
itemsListState = null;
|
||||
}
|
||||
|
||||
whatsNewItemListHeader.setVisibility(View.VISIBLE);
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
@ -231,12 +426,6 @@ public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEnt
|
|||
animateView(itemsList, true, 200);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showEmptyState() {
|
||||
super.showEmptyState();
|
||||
animateView(itemsList, false, 200);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Error Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -0,0 +1,210 @@
|
|||
package org.schabi.newpipe.fragments.subscription;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.StringRes;
|
||||
import android.support.v4.text.util.LinkifyCompat;
|
||||
import android.support.v7.app.ActionBar;
|
||||
import android.text.TextUtils;
|
||||
import android.text.util.Linkify;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import icepick.State;
|
||||
|
||||
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||
import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
|
||||
public class SubscriptionsImportFragment extends BaseFragment {
|
||||
private static final int REQUEST_IMPORT_FILE_CODE = 666;
|
||||
|
||||
@State
|
||||
protected int currentServiceId = Constants.NO_SERVICE_ID;
|
||||
|
||||
private List<SubscriptionExtractor.ContentSource> supportedSources;
|
||||
private String relatedUrl;
|
||||
@StringRes
|
||||
private int instructionsString;
|
||||
|
||||
public static SubscriptionsImportFragment getInstance(int serviceId) {
|
||||
SubscriptionsImportFragment instance = new SubscriptionsImportFragment();
|
||||
instance.setInitialData(serviceId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void setInitialData(int serviceId) {
|
||||
this.currentServiceId = serviceId;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private TextView infoTextView;
|
||||
|
||||
private EditText inputText;
|
||||
private Button inputButton;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setupServiceVariables();
|
||||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
ErrorActivity.reportError(activity, Collections.emptyList(), null, null, ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE,
|
||||
NewPipe.getNameOfService(currentServiceId), "Service don't support importing", R.string.general_error));
|
||||
activity.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
||||
super.setUserVisibleHint(isVisibleToUser);
|
||||
if (isVisibleToUser) {
|
||||
setTitle(getString(R.string.import_title));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_import, container, false);
|
||||
}
|
||||
|
||||
/*/////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Views
|
||||
/////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
inputButton = rootView.findViewById(R.id.input_button);
|
||||
inputText = rootView.findViewById(R.id.input_text);
|
||||
|
||||
infoTextView = rootView.findViewById(R.id.info_text_view);
|
||||
|
||||
// TODO: Support services that can import from more than one source (show the option to the user)
|
||||
if (supportedSources.contains(CHANNEL_URL)) {
|
||||
inputButton.setText(R.string.import_title);
|
||||
inputText.setVisibility(View.VISIBLE);
|
||||
inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId));
|
||||
} else {
|
||||
inputButton.setText(R.string.import_file_title);
|
||||
}
|
||||
|
||||
if (instructionsString != 0) {
|
||||
if (TextUtils.isEmpty(relatedUrl)) {
|
||||
setInfoText(getString(instructionsString));
|
||||
} else {
|
||||
setInfoText(getString(instructionsString, relatedUrl));
|
||||
}
|
||||
} else {
|
||||
setInfoText("");
|
||||
}
|
||||
|
||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null) {
|
||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
||||
setTitle(getString(R.string.import_title));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
inputButton.setOnClickListener(v -> onImportClicked());
|
||||
}
|
||||
|
||||
private void onImportClicked() {
|
||||
if (inputText.getVisibility() == View.VISIBLE) {
|
||||
final String value = inputText.getText().toString();
|
||||
if (!value.isEmpty()) onImportUrl(value);
|
||||
} else {
|
||||
onImportFile();
|
||||
}
|
||||
}
|
||||
|
||||
public void onImportUrl(String value) {
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, CHANNEL_URL_MODE)
|
||||
.putExtra(KEY_VALUE, value)
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
}
|
||||
|
||||
public void onImportFile() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_FILE_CODE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (data == null) return;
|
||||
|
||||
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE && data.getData() != null) {
|
||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
|
||||
.putExtra(KEY_VALUE, path)
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Subscriptions
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void setupServiceVariables() {
|
||||
if (currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
try {
|
||||
final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId).getSubscriptionExtractor();
|
||||
supportedSources = extractor.getSupportedSources();
|
||||
relatedUrl = extractor.getRelatedUrl();
|
||||
instructionsString = ServiceHelper.getImportInstructions(currentServiceId);
|
||||
return;
|
||||
} catch (ExtractionException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
supportedSources = Collections.emptyList();
|
||||
relatedUrl = null;
|
||||
instructionsString = 0;
|
||||
}
|
||||
|
||||
private void setInfoText(String infoString) {
|
||||
infoTextView.setText(infoString);
|
||||
LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS);
|
||||
}
|
||||
}
|
|
@ -32,12 +32,8 @@ import java.util.List;
|
|||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL;
|
||||
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
||||
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT;
|
||||
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH;
|
||||
import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
||||
|
||||
public class PlayerHelper {
|
||||
|
@ -162,7 +158,7 @@ public class PlayerHelper {
|
|||
return isUsingOldPlayer(context, false);
|
||||
}
|
||||
|
||||
public static boolean isRememberingPopupDimensions(@Nonnull final Context context) {
|
||||
public static boolean isRememberingPopupDimensions(@NonNull final Context context) {
|
||||
return isRememberingPopupDimensions(context, true);
|
||||
}
|
||||
|
||||
|
@ -211,11 +207,11 @@ public class PlayerHelper {
|
|||
return true;
|
||||
}
|
||||
|
||||
public static int getShutdownFlingVelocity(@Nonnull final Context context) {
|
||||
public static int getShutdownFlingVelocity(@NonNull final Context context) {
|
||||
return 10000;
|
||||
}
|
||||
|
||||
public static int getTossFlingVelocity(@Nonnull final Context context) {
|
||||
public static int getTossFlingVelocity(@NonNull final Context context) {
|
||||
return 2500;
|
||||
}
|
||||
|
||||
|
@ -240,7 +236,7 @@ public class PlayerHelper {
|
|||
return getPreferences(context).getBoolean(context.getString(R.string.use_old_player_key), b);
|
||||
}
|
||||
|
||||
private static boolean isRememberingPopupDimensions(@Nonnull final Context context, final boolean b) {
|
||||
private static boolean isRememberingPopupDimensions(@NonNull final Context context, final boolean b) {
|
||||
return getPreferences(context).getBoolean(context.getString(R.string.popup_remember_size_pos_key), b);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,11 +5,14 @@ import android.app.AlertDialog;
|
|||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.v7.preference.ListPreference;
|
||||
import android.support.v7.preference.Preference;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
|
@ -34,8 +37,6 @@ import java.util.zip.ZipFile;
|
|||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
private static final int REQUEST_IMPORT_PATH = 8945;
|
||||
|
@ -140,15 +141,15 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, @Nonnull Intent data) {
|
||||
public void onActivityResult(int requestCode, int resultCode, @NonNull Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]");
|
||||
}
|
||||
|
||||
if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH)
|
||||
&& resultCode == Activity.RESULT_OK) {
|
||||
String path = data.getData().getPath();
|
||||
&& resultCode == Activity.RESULT_OK && data.getData() != null) {
|
||||
String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
if (requestCode == REQUEST_EXPORT_PATH) {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
|
||||
exportDatabase(path + "/NewPipeData-" + sdf.format(new Date()) + ".zip");
|
||||
|
|
|
@ -7,6 +7,8 @@ import android.support.annotation.Nullable;
|
|||
import android.support.v7.preference.Preference;
|
||||
import android.util.Log;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
|
||||
|
@ -69,9 +71,11 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
|||
Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]");
|
||||
}
|
||||
|
||||
if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) && resultCode == Activity.RESULT_OK) {
|
||||
if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH)
|
||||
&& resultCode == Activity.RESULT_OK && data.getData() != null) {
|
||||
String key = getString(requestCode == REQUEST_DOWNLOAD_PATH ? R.string.download_path_key : R.string.download_path_audio_key);
|
||||
String path = data.getData().getPath();
|
||||
String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
|
||||
defaultPreferences.edit().putString(key, path).apply();
|
||||
updatePreferencesSummary();
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ package org.schabi.newpipe.settings;
|
|||
import android.app.Activity;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.v4.app.DialogFragment;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.fragments.subscription.SubscriptionService;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.SubscriptionService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
|
@ -87,7 +87,7 @@ public class SelectChannelFragment extends DialogFragment {
|
|||
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View v = inflater.inflate(R.layout.select_channel_fragment, container, false);
|
||||
recyclerView = (RecyclerView) v.findViewById(R.id.items_list);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
|
@ -101,7 +101,7 @@ public class SelectChannelFragment extends DialogFragment {
|
|||
emptyView.setVisibility(View.GONE);
|
||||
|
||||
|
||||
subscriptionService = SubscriptionService.getInstance();
|
||||
subscriptionService = SubscriptionService.getInstance(getContext());
|
||||
subscriptionService.getSubscription().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package org.schabi.newpipe.subscription;
|
||||
|
||||
public interface ImportExportEventListener {
|
||||
/**
|
||||
* Called when the size has been resolved.
|
||||
*
|
||||
* @param size how many items there are to import/export
|
||||
*/
|
||||
void onSizeReceived(int size);
|
||||
|
||||
/**
|
||||
* Called everytime an item has been parsed/resolved.
|
||||
*
|
||||
* @param itemName the name of the subscription item
|
||||
*/
|
||||
void onItemCompleted(String itemName);
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ImportExportJsonHelper.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.subscription;
|
||||
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import com.grack.nanojson.JsonAppendableWriter;
|
||||
import com.grack.nanojson.JsonArray;
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonSink;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
|
||||
* of being able to transfer subscriptions to any device.
|
||||
*/
|
||||
public class ImportExportJsonHelper {
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Json implementation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private static final String JSON_APP_VERSION_KEY = "app_version";
|
||||
private static final String JSON_APP_VERSION_INT_KEY = "app_version_int";
|
||||
|
||||
private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions";
|
||||
|
||||
private static final String JSON_SERVICE_ID_KEY = "service_id";
|
||||
private static final String JSON_URL_KEY = "url";
|
||||
private static final String JSON_NAME_KEY = "name";
|
||||
|
||||
/**
|
||||
* Read a JSON source through the input stream and return the parsed subscription items.
|
||||
*
|
||||
* @param in the input stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static List<SubscriptionItem> readFrom(InputStream in, @Nullable ImportExportEventListener eventListener) throws InvalidSourceException {
|
||||
if (in == null) throw new InvalidSourceException("input is null");
|
||||
|
||||
final List<SubscriptionItem> channels = new ArrayList<>();
|
||||
|
||||
try {
|
||||
JsonObject parentObject = JsonParser.object().from(in);
|
||||
JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
if (eventListener != null) eventListener.onSizeReceived(channelsArray.size());
|
||||
|
||||
if (channelsArray == null) {
|
||||
throw new InvalidSourceException("Channels array is null");
|
||||
}
|
||||
|
||||
for (Object o : channelsArray) {
|
||||
if (o instanceof JsonObject) {
|
||||
JsonObject itemObject = (JsonObject) o;
|
||||
int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0);
|
||||
String url = itemObject.getString(JSON_URL_KEY);
|
||||
String name = itemObject.getString(JSON_NAME_KEY);
|
||||
|
||||
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
|
||||
channels.add(new SubscriptionItem(serviceId, url, name));
|
||||
if (eventListener != null) eventListener.onItemCompleted(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
throw new InvalidSourceException("Couldn't parse json", e);
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the subscriptions items list as JSON to the output.
|
||||
*
|
||||
* @param items the list of subscriptions items
|
||||
* @param out the output stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(List<SubscriptionItem> items, OutputStream out, @Nullable ImportExportEventListener eventListener) {
|
||||
JsonAppendableWriter writer = JsonWriter.on(out);
|
||||
writeTo(items, writer, eventListener);
|
||||
writer.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||
*/
|
||||
public static void writeTo(List<SubscriptionItem> items, JsonSink writer, @Nullable ImportExportEventListener eventListener) {
|
||||
if (eventListener != null) eventListener.onSizeReceived(items.size());
|
||||
|
||||
writer.object();
|
||||
|
||||
writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME);
|
||||
writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE);
|
||||
|
||||
writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
for (SubscriptionItem item : items) {
|
||||
writer.object();
|
||||
writer.value(JSON_SERVICE_ID_KEY, item.getServiceId());
|
||||
writer.value(JSON_URL_KEY, item.getUrl());
|
||||
writer.value(JSON_NAME_KEY, item.getName());
|
||||
writer.end();
|
||||
|
||||
if (eventListener != null) eventListener.onItemCompleted(item.getName());
|
||||
}
|
||||
writer.end();
|
||||
|
||||
writer.end();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package org.schabi.newpipe.fragments.subscription;
|
||||
package org.schabi.newpipe.subscription;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.util.Log;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
|
@ -10,6 +12,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
@ -20,7 +23,6 @@ import io.reactivex.CompletableSource;
|
|||
import io.reactivex.Flowable;
|
||||
import io.reactivex.Maybe;
|
||||
import io.reactivex.Scheduler;
|
||||
import io.reactivex.annotations.NonNull;
|
||||
import io.reactivex.functions.Function;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
|
@ -32,10 +34,20 @@ import io.reactivex.schedulers.Schedulers;
|
|||
*/
|
||||
public class SubscriptionService {
|
||||
|
||||
private static final SubscriptionService sInstance = new SubscriptionService();
|
||||
private static volatile SubscriptionService instance;
|
||||
|
||||
public static SubscriptionService getInstance() {
|
||||
return sInstance;
|
||||
public static SubscriptionService getInstance(@NonNull Context context) {
|
||||
SubscriptionService result = instance;
|
||||
if (result == null) {
|
||||
synchronized (SubscriptionService.class) {
|
||||
result = instance;
|
||||
if (result == null) {
|
||||
instance = (result = new SubscriptionService(context));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
|
||||
|
@ -48,8 +60,8 @@ public class SubscriptionService {
|
|||
|
||||
private Scheduler subscriptionScheduler;
|
||||
|
||||
private SubscriptionService() {
|
||||
db = NewPipeDatabase.getInstance();
|
||||
private SubscriptionService(Context context) {
|
||||
db = NewPipeDatabase.getInstance(context.getApplicationContext());
|
||||
subscription = getSubscriptionInfos();
|
||||
|
||||
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
|
||||
|
@ -114,7 +126,7 @@ public class SubscriptionService {
|
|||
if (!isSubscriptionUpToDate(info, subscription)) {
|
||||
subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
|
||||
|
||||
return update(subscription);
|
||||
return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,13 +139,11 @@ public class SubscriptionService {
|
|||
.flatMapCompletable(update);
|
||||
}
|
||||
|
||||
private Completable update(final SubscriptionEntity updatedSubscription) {
|
||||
return Completable.fromRunnable(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
subscriptionTable().update(updatedSubscription);
|
||||
}
|
||||
});
|
||||
public List<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
|
||||
final List<SubscriptionEntity> entityList = new ArrayList<>();
|
||||
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
|
||||
|
||||
return subscriptionTable().upsertAll(entityList);
|
||||
}
|
||||
|
||||
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* BaseImportExportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.subscription.services;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.StringRes;
|
||||
import android.support.v4.app.NotificationCompat;
|
||||
import android.support.v4.app.NotificationManagerCompat;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
import org.schabi.newpipe.subscription.ImportExportEventListener;
|
||||
import org.schabi.newpipe.subscription.SubscriptionService;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.reactivex.Flowable;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.CompositeDisposable;
|
||||
import io.reactivex.functions.Function;
|
||||
import io.reactivex.processors.PublishProcessor;
|
||||
|
||||
public abstract class BaseImportExportService extends Service {
|
||||
protected final String TAG = this.getClass().getSimpleName();
|
||||
|
||||
protected NotificationManagerCompat notificationManager;
|
||||
protected NotificationCompat.Builder notificationBuilder;
|
||||
|
||||
protected SubscriptionService subscriptionService;
|
||||
protected CompositeDisposable disposables = new CompositeDisposable();
|
||||
protected PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
subscriptionService = SubscriptionService.getInstance(this);
|
||||
setupNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposeAll();
|
||||
}
|
||||
|
||||
protected void disposeAll() {
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Notification Impl
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
|
||||
|
||||
protected AtomicInteger currentProgress = new AtomicInteger(-1);
|
||||
protected AtomicInteger maxProgress = new AtomicInteger(-1);
|
||||
protected ImportExportEventListener eventListener = new ImportExportEventListener() {
|
||||
@Override
|
||||
public void onSizeReceived(int size) {
|
||||
maxProgress.set(size);
|
||||
currentProgress.set(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemCompleted(String itemName) {
|
||||
currentProgress.incrementAndGet();
|
||||
notificationUpdater.onNext(itemName);
|
||||
}
|
||||
};
|
||||
|
||||
protected abstract int getNotificationId();
|
||||
@StringRes
|
||||
public abstract int getTitle();
|
||||
|
||||
protected void setupNotification() {
|
||||
notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationBuilder = createNotification();
|
||||
startForeground(getNotificationId(), notificationBuilder.build());
|
||||
|
||||
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow -> flow.limit(1)
|
||||
.concatWith(flow.skip(1).throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
|
||||
|
||||
disposables.add(notificationUpdater
|
||||
.filter(s -> !s.isEmpty())
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotification));
|
||||
}
|
||||
|
||||
protected void updateNotification(String text) {
|
||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
|
||||
|
||||
final String progressText = currentProgress + "/" + maxProgress;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!TextUtils.isEmpty(text)) text = text + " (" + progressText + ")";
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(text)) notificationBuilder.setContentText(text);
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected void stopService() {
|
||||
postErrorResult(null, null);
|
||||
}
|
||||
|
||||
protected void stopAndReportError(@Nullable Throwable error, String request) {
|
||||
stopService();
|
||||
|
||||
final ErrorActivity.ErrorInfo errorInfo = ErrorActivity.ErrorInfo.make(UserAction.SUBSCRIPTION, "unknown",
|
||||
request, R.string.general_error);
|
||||
ErrorActivity.reportError(this, error != null ? Collections.singletonList(error) : Collections.emptyList(),
|
||||
null, null, errorInfo);
|
||||
}
|
||||
|
||||
protected void postErrorResult(String title, String text) {
|
||||
disposeAll();
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
|
||||
if (title == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
text = text == null ? "" : text;
|
||||
notificationBuilder = new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentTitle(title)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentText(text);
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected NotificationCompat.Builder createNotification() {
|
||||
return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentTitle(getString(getTitle()));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Toast
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected Toast toast;
|
||||
|
||||
protected void showToast(@StringRes int message) {
|
||||
showToast(getString(message), Toast.LENGTH_SHORT);
|
||||
}
|
||||
|
||||
protected void showToast(String message, int duration) {
|
||||
if (toast != null) toast.cancel();
|
||||
|
||||
toast = Toast.makeText(this, message, duration);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Error handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected void handleError(@StringRes int errorTitle, @NonNull Throwable error) {
|
||||
String message = getErrorMessage(error);
|
||||
|
||||
if (TextUtils.isEmpty(message)) {
|
||||
final String errorClassName = error.getClass().getName();
|
||||
message = getString(R.string.error_occurred_detail, errorClassName);
|
||||
}
|
||||
|
||||
showToast(errorTitle);
|
||||
postErrorResult(getString(errorTitle), message);
|
||||
}
|
||||
|
||||
protected String getErrorMessage(Throwable error) {
|
||||
String message = null;
|
||||
if (error instanceof SubscriptionExtractor.InvalidSourceException) {
|
||||
message = getString(R.string.invalid_source);
|
||||
} else if (error instanceof FileNotFoundException) {
|
||||
message = getString(R.string.invalid_file);
|
||||
} else if (error instanceof IOException) {
|
||||
message = getString(R.string.network_error);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsExportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.subscription.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.subscription.ImportExportJsonHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.functions.Function;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
public class SubscriptionsExportService extends BaseImportExportService {
|
||||
public static final String KEY_FILE_PATH = "key_file_path";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action when the export is successfully completed.
|
||||
*/
|
||||
public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipe.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE";
|
||||
|
||||
private Subscription subscription;
|
||||
private File outFile;
|
||||
private FileOutputStream outputStream;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent == null || subscription != null) return START_NOT_STICKY;
|
||||
|
||||
final String path = intent.getStringExtra(KEY_FILE_PATH);
|
||||
if (TextUtils.isEmpty(path)) {
|
||||
stopAndReportError(new IllegalStateException("Exporting to a file, but the path is empty or null"), "Exporting subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
outputStream = new FileOutputStream(outFile = new File(path));
|
||||
} catch (FileNotFoundException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startExport();
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4567;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.export_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) subscription.cancel();
|
||||
}
|
||||
|
||||
private void startExport() {
|
||||
showToast(R.string.export_ongoing);
|
||||
|
||||
subscriptionService.subscriptionTable()
|
||||
.getAll()
|
||||
.take(1)
|
||||
.map(subscriptionEntities -> {
|
||||
final List<SubscriptionItem> result = new ArrayList<>(subscriptionEntities.size());
|
||||
for (SubscriptionEntity entity : subscriptionEntities) {
|
||||
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), entity.getName()));
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.map(exportToFile())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<File> getSubscriber() {
|
||||
return new Subscriber<File>() {
|
||||
@Override
|
||||
public void onSubscribe(Subscription s) {
|
||||
subscription = s;
|
||||
s.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(File file) {
|
||||
if (DEBUG) Log.d(TAG, "startExport() success: file = " + file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
Log.e(TAG, "onError() called with: error = [" + error + "]", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsExportService.this).sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.export_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<SubscriptionItem>, File> exportToFile() {
|
||||
return subscriptionItems -> {
|
||||
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
|
||||
return outFile;
|
||||
};
|
||||
}
|
||||
|
||||
protected void handleError(Throwable error) {
|
||||
super.handleError(R.string.subscriptions_export_unsuccessful, error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsImportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.subscription.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.content.LocalBroadcastManager;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.subscription.ImportExportJsonHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.Flowable;
|
||||
import io.reactivex.Notification;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.functions.Consumer;
|
||||
import io.reactivex.functions.Function;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
public class SubscriptionsImportService extends BaseImportExportService {
|
||||
public static final int CHANNEL_URL_MODE = 0;
|
||||
public static final int INPUT_STREAM_MODE = 1;
|
||||
public static final int PREVIOUS_EXPORT_MODE = 2;
|
||||
public static final String KEY_MODE = "key_mode";
|
||||
public static final String KEY_VALUE = "key_value";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action when the import is successfully completed.
|
||||
*/
|
||||
public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipe.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE";
|
||||
|
||||
private Subscription subscription;
|
||||
private int currentMode;
|
||||
private int currentServiceId;
|
||||
|
||||
@Nullable
|
||||
private String channelUrl;
|
||||
@Nullable
|
||||
private InputStream inputStream;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent == null || subscription != null) return START_NOT_STICKY;
|
||||
|
||||
currentMode = intent.getIntExtra(KEY_MODE, -1);
|
||||
currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID);
|
||||
|
||||
if (currentMode == CHANNEL_URL_MODE) {
|
||||
channelUrl = intent.getStringExtra(KEY_VALUE);
|
||||
} else {
|
||||
final String filePath = intent.getStringExtra(KEY_VALUE);
|
||||
if (TextUtils.isEmpty(filePath)) {
|
||||
stopAndReportError(new IllegalStateException("Importing from input stream, but file path is empty or null"), "Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
inputStream = new FileInputStream(new File(filePath));
|
||||
} catch (FileNotFoundException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) {
|
||||
final String errorDescription = "Some important field is null or in illegal state: currentMode=[" + currentMode + "], channelUrl=[" + channelUrl + "], inputStream=[" + inputStream + "]";
|
||||
stopAndReportError(new IllegalStateException(errorDescription), "Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startImport();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4568;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.import_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) subscription.cancel();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Imports
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* How many extractions running in parallel.
|
||||
*/
|
||||
public static final int PARALLEL_EXTRACTIONS = 8;
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the subscriptions table, this leads to
|
||||
* a better performance as we can then use db transactions.
|
||||
*/
|
||||
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
|
||||
|
||||
private void startImport() {
|
||||
showToast(R.string.import_ongoing);
|
||||
|
||||
Flowable<List<SubscriptionItem>> flowable = null;
|
||||
if (currentMode == CHANNEL_URL_MODE) {
|
||||
flowable = importFromChannelUrl();
|
||||
} else if (currentMode == INPUT_STREAM_MODE) {
|
||||
flowable = importFromInputStream();
|
||||
} else if (currentMode == PREVIOUS_EXPORT_MODE) {
|
||||
flowable = importFromPreviousExport();
|
||||
}
|
||||
|
||||
if (flowable == null) {
|
||||
final String message = "Flowable given by \"importFrom\" is null (current mode: " + currentMode + ")";
|
||||
stopAndReportError(new IllegalStateException(message), "Importing subscriptions");
|
||||
return;
|
||||
}
|
||||
|
||||
flowable.doOnNext(subscriptionItems -> eventListener.onSizeReceived(subscriptionItems.size()))
|
||||
.flatMap(Flowable::fromIterable)
|
||||
|
||||
.parallel(PARALLEL_EXTRACTIONS)
|
||||
.runOn(Schedulers.io())
|
||||
.map((Function<SubscriptionItem, Notification<ChannelInfo>>) subscriptionItem -> {
|
||||
try {
|
||||
return Notification.createOnNext(ExtractorHelper
|
||||
.getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true)
|
||||
.blockingGet());
|
||||
} catch (Throwable e) {
|
||||
return Notification.createOnError(e);
|
||||
}
|
||||
})
|
||||
.sequential()
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.doOnNext(getNotificationsConsumer())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.map(upsertBatch())
|
||||
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
|
||||
return new Subscriber<List<SubscriptionEntity>>() {
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Subscription s) {
|
||||
subscription = s;
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(List<SubscriptionEntity> successfulInserted) {
|
||||
if (DEBUG) Log.d(TAG, "startImport() " + successfulInserted.size() + " items successfully inserted into the database");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsImportService.this).sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.import_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Consumer<Notification<ChannelInfo>> getNotificationsConsumer() {
|
||||
return notification -> {
|
||||
if (notification.isOnNext()) {
|
||||
String name = notification.getValue().getName();
|
||||
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
|
||||
} else if (notification.isOnError()) {
|
||||
final Throwable error = notification.getError();
|
||||
final Throwable cause = error.getCause();
|
||||
if (error instanceof IOException) {
|
||||
throw (IOException) error;
|
||||
} else if (cause != null && cause instanceof IOException) {
|
||||
throw (IOException) cause;
|
||||
}
|
||||
|
||||
eventListener.onItemCompleted("");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<Notification<ChannelInfo>>, List<SubscriptionEntity>> upsertBatch() {
|
||||
return notificationList -> {
|
||||
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size());
|
||||
for (Notification<ChannelInfo> n : notificationList) {
|
||||
if (n.isOnNext()) infoList.add(n.getValue());
|
||||
}
|
||||
|
||||
return subscriptionService.upsertAll(infoList);
|
||||
};
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromChannelUrl(channelUrl));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromInputStream() {
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromInputStream(inputStream));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
|
||||
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
|
||||
}
|
||||
|
||||
protected void handleError(@NonNull Throwable error) {
|
||||
super.handleError(R.string.subscriptions_import_unsuccessful, error);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,22 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* AnimationUtils.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.animation.Animator;
|
||||
|
@ -19,7 +38,9 @@ public class AnimationUtils {
|
|||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
public enum Type {
|
||||
ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA
|
||||
ALPHA,
|
||||
SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA,
|
||||
SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA
|
||||
}
|
||||
|
||||
public static void animateView(View view, boolean enterOrExit, long duration) {
|
||||
|
@ -168,6 +189,58 @@ public class AnimationUtils {
|
|||
viewPropertyAnimator.start();
|
||||
}
|
||||
|
||||
public static ValueAnimator animateHeight(final View view, long duration, int targetHeight) {
|
||||
final int height = view.getHeight();
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateHeight: duration = [" + duration + "], from " + height + " to → " + targetHeight + " in: " + view);
|
||||
}
|
||||
|
||||
ValueAnimator animator = ValueAnimator.ofFloat(height, targetHeight);
|
||||
animator.setInterpolator(new FastOutSlowInInterpolator());
|
||||
animator.setDuration(duration);
|
||||
animator.addUpdateListener(animation -> {
|
||||
final float value = (float) animation.getAnimatedValue();
|
||||
view.getLayoutParams().height = (int) value;
|
||||
view.requestLayout();
|
||||
});
|
||||
animator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
view.getLayoutParams().height = targetHeight;
|
||||
view.requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationCancel(Animator animation) {
|
||||
view.getLayoutParams().height = targetHeight;
|
||||
view.requestLayout();
|
||||
}
|
||||
});
|
||||
animator.start();
|
||||
|
||||
return animator;
|
||||
}
|
||||
|
||||
public static void animateRotation(final View view, long duration, int targetRotation) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateRotation: duration = [" + duration + "], from " + view.getRotation() + " to → " + targetRotation + " in: " + view);
|
||||
}
|
||||
view.animate().setListener(null).cancel();
|
||||
|
||||
view.animate().rotation(targetRotation).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator())
|
||||
.setListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationCancel(Animator animation) {
|
||||
view.setRotation(targetRotation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
view.setRotation(targetRotation);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Internals
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
|
|
@ -1,10 +1,32 @@
|
|||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.content.Loader;
|
||||
import android.support.v7.util.SortedList;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.util.TypedValue;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment;
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity {
|
||||
|
||||
private CustomFilePickerFragment currentFragment;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
if(ThemeHelper.isLightThemeSelected(this)) {
|
||||
|
@ -14,4 +36,98 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File
|
|||
}
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// If at top most level, normal behaviour
|
||||
if (currentFragment.isBackTop()) {
|
||||
super.onBackPressed();
|
||||
} else {
|
||||
// Else go up
|
||||
currentFragment.goUp();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractFilePickerFragment<File> getFragment(@Nullable String startPath, int mode, boolean allowMultiple, boolean allowCreateDir, boolean allowExistingFile, boolean singleClick) {
|
||||
final CustomFilePickerFragment fragment = new CustomFilePickerFragment();
|
||||
fragment.setArgs(startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(),
|
||||
mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick);
|
||||
return currentFragment = fragment;
|
||||
}
|
||||
|
||||
public static Intent chooseSingleFile(@NonNull Context context) {
|
||||
return new Intent(context, FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE);
|
||||
}
|
||||
|
||||
public static Intent chooseFileToSave(@NonNull Context context, @Nullable String startPath) {
|
||||
return new Intent(context, FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_NEW_FILE);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Internal
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static class CustomFilePickerFragment extends FilePickerFragment {
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
return super.onCreateView(inflater, container, savedInstanceState);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType);
|
||||
|
||||
final View view = viewHolder.itemView.findViewById(android.R.id.text1);
|
||||
if (view instanceof TextView) {
|
||||
((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.file_picker_items_text_size));
|
||||
}
|
||||
|
||||
return viewHolder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClickOk(@NonNull View view) {
|
||||
if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) {
|
||||
if (mToast != null) mToast.cancel();
|
||||
mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, Toast.LENGTH_SHORT);
|
||||
mToast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
super.onClickOk(view);
|
||||
}
|
||||
|
||||
public File getBackTop() {
|
||||
if (getArguments() == null) return Environment.getExternalStorageDirectory();
|
||||
|
||||
final String path = getArguments().getString(KEY_START_PATH, "/");
|
||||
if (path.contains(Environment.getExternalStorageDirectory().getPath())) {
|
||||
return Environment.getExternalStorageDirectory();
|
||||
}
|
||||
|
||||
return getPath(path);
|
||||
}
|
||||
|
||||
public boolean isBackTop() {
|
||||
return compareFiles(mCurrentPath, getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(Loader<SortedList<File>> loader, SortedList<File> data) {
|
||||
super.onLoadFinished(loader, data);
|
||||
layoutManager.scrollToPosition(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
|
@ -11,6 +12,7 @@ import android.support.annotation.NonNull;
|
|||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.support.v4.app.FragmentManager;
|
||||
import android.support.v4.app.FragmentTransaction;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
@ -38,6 +40,7 @@ import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
|||
import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment;
|
||||
import org.schabi.newpipe.fragments.local.bookmark.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.fragments.local.bookmark.MostPlayedFragment;
|
||||
import org.schabi.newpipe.fragments.subscription.SubscriptionsImportFragment;
|
||||
import org.schabi.newpipe.history.HistoryActivity;
|
||||
import org.schabi.newpipe.player.BackgroundPlayer;
|
||||
import org.schabi.newpipe.player.BackgroundPlayerActivity;
|
||||
|
@ -247,6 +250,12 @@ public class NavigationHelper {
|
|||
// Through FragmentManager
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@SuppressLint("CommitTransaction")
|
||||
private static FragmentTransaction defaultTransaction(FragmentManager fragmentManager) {
|
||||
return fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out);
|
||||
}
|
||||
|
||||
public static void gotoMainFragment(FragmentManager fragmentManager) {
|
||||
ImageLoader.getInstance().clearMemoryCache();
|
||||
|
||||
|
@ -258,8 +267,7 @@ public class NavigationHelper {
|
|||
InfoCache.getInstance().trimCache();
|
||||
|
||||
fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, new MainFragment())
|
||||
.addToBackStack(MAIN_FRAGMENT_TAG)
|
||||
.commit();
|
||||
|
@ -276,8 +284,7 @@ public class NavigationHelper {
|
|||
}
|
||||
|
||||
public static void openSearchFragment(FragmentManager fragmentManager, int serviceId, String query) {
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, query))
|
||||
.addToBackStack(SEARCH_FRAGMENT_TAG)
|
||||
.commit();
|
||||
|
@ -301,8 +308,7 @@ public class NavigationHelper {
|
|||
VideoDetailFragment instance = VideoDetailFragment.getInstance(serviceId, url, title);
|
||||
instance.setAutoplay(autoPlay);
|
||||
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, instance)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
|
@ -310,8 +316,7 @@ public class NavigationHelper {
|
|||
|
||||
public static void openChannelFragment(FragmentManager fragmentManager, int serviceId, String url, String name) {
|
||||
if (name == null) name = "";
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, name))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
|
@ -319,25 +324,21 @@ public class NavigationHelper {
|
|||
|
||||
public static void openPlaylistFragment(FragmentManager fragmentManager, int serviceId, String url, String name) {
|
||||
if (name == null) name = "";
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openWhatsNewFragment(FragmentManager fragmentManager) {
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, new FeedFragment())
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId)
|
||||
throws ExtractionException {
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId) throws ExtractionException {
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
|
@ -345,28 +346,33 @@ public class NavigationHelper {
|
|||
|
||||
public static void openLocalPlaylistFragment(FragmentManager fragmentManager, long playlistId, String name) {
|
||||
if (name == null) name = "";
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, name))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openLastPlayedFragment(FragmentManager fragmentManager) {
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, new LastPlayedFragment())
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openMostPlayedFragment(FragmentManager fragmentManager) {
|
||||
fragmentManager.beginTransaction()
|
||||
.setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out)
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, new MostPlayedFragment())
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public static void openSubscriptionsImportFragment(FragmentManager fragmentManager, int serviceId) {
|
||||
defaultTransaction(fragmentManager)
|
||||
.replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Through Intents
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.widget.PopupMenu;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Created by Christian Schabesberger on 20.01.18.
|
||||
* Copyright 2018 Christian Schabesberger <chris.schabesberger@mailbox.org>
|
||||
* PopupMenuIconHacker.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class PopupMenuIconHacker {
|
||||
public static void setShowPopupIcon(PopupMenu menu) throws Exception {
|
||||
try {
|
||||
Field[] fields = menu.getClass().getDeclaredFields();
|
||||
for (Field field : fields) {
|
||||
if ("mPopup".equals(field.getName())) {
|
||||
field.setAccessible(true);
|
||||
Object menuPopupHelper = field.get(menu);
|
||||
Class<?> classPopupHelper = Class.forName(menuPopupHelper
|
||||
.getClass().getName());
|
||||
Method setForceIcons = classPopupHelper.getMethod(
|
||||
"setForceShowIcon", boolean.class);
|
||||
setForceIcons.invoke(menuPopupHelper, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new Exception("Could not make Popup menu show Icons", e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.util;
|
|||
import android.content.Context;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.support.annotation.DrawableRes;
|
||||
import android.support.annotation.StringRes;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.R;
|
||||
|
@ -26,6 +27,39 @@ public class ServiceHelper {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource string with instructions for importing subscriptions for each service.
|
||||
*
|
||||
* @return the string resource containing the instructions or -1 if the service don't support it
|
||||
*/
|
||||
@StringRes
|
||||
public static int getImportInstructions(int serviceId) {
|
||||
switch (serviceId) {
|
||||
case 0:
|
||||
return R.string.import_youtube_instructions;
|
||||
case 1:
|
||||
return R.string.import_soundcloud_instructions;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For services that support importing from a channel url, return a hint that will
|
||||
* be used in the EditText that the user will type in his channel url.
|
||||
*
|
||||
* @return the hint's string resource or -1 if the service don't support it
|
||||
*/
|
||||
@StringRes
|
||||
public static int getImportInstructionsHint(int serviceId) {
|
||||
switch (serviceId) {
|
||||
case 1:
|
||||
return R.string.import_soundcloud_instructions_hint;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static int getSelectedServiceId(Context context) {
|
||||
if (BuildConfig.BUILD_TYPE.equals("release")) return DEFAULT_FALLBACK_SERVICE.getServiceId();
|
||||
|
||||
|
|
|
@ -1,3 +1,22 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ThemeHelper.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
|
@ -5,6 +24,9 @@ import android.content.res.TypedArray;
|
|||
import android.preference.PreferenceManager;
|
||||
import android.support.annotation.AttrRes;
|
||||
import android.support.annotation.StyleRes;
|
||||
import android.support.v4.content.ContextCompat;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ContextThemeWrapper;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
|
@ -41,16 +63,57 @@ public class ThemeHelper {
|
|||
* @param context context to get the preference
|
||||
*/
|
||||
public static boolean isLightThemeSelected(Context context) {
|
||||
return getSelectedTheme(context).equals(context.getResources().getString(R.string.light_theme_key));
|
||||
return getSelectedThemeString(context).equals(context.getResources().getString(R.string.light_theme_key));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create and return a wrapped context with the default selected theme set.
|
||||
*
|
||||
* @param baseContext the base context for the wrapper
|
||||
* @return a wrapped-styled context
|
||||
*/
|
||||
public static Context getThemedContext(Context baseContext) {
|
||||
return new ContextThemeWrapper(baseContext, getThemeForService(baseContext, -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected theme without being styled to any service (see {@link #getThemeForService(Context, int)}).
|
||||
*
|
||||
* @param context context to get the selected theme
|
||||
* @return the selected style (the default one)
|
||||
*/
|
||||
@StyleRes
|
||||
public static int getDefaultTheme(Context context) {
|
||||
return getThemeForService(context, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a dialog theme styled according to the (default) selected theme.
|
||||
*
|
||||
* @param context context to get the selected theme
|
||||
* @return the dialog style (the default one)
|
||||
*/
|
||||
@StyleRes
|
||||
public static int getDialogTheme(Context context) {
|
||||
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected theme styled according to the serviceId.
|
||||
*
|
||||
* @param context context to get the selected theme
|
||||
* @param serviceId return a theme styled to this service,
|
||||
* -1 to get the default
|
||||
* @return the selected style (styled)
|
||||
*/
|
||||
@StyleRes
|
||||
public static int getThemeForService(Context context, int serviceId) {
|
||||
String lightTheme = context.getResources().getString(R.string.light_theme_key);
|
||||
String darkTheme = context.getResources().getString(R.string.dark_theme_key);
|
||||
String blackTheme = context.getResources().getString(R.string.black_theme_key);
|
||||
|
||||
String selectedTheme = getSelectedTheme(context);
|
||||
String selectedTheme = getSelectedThemeString(context);
|
||||
|
||||
int defaultTheme = R.style.DarkTheme;
|
||||
if (selectedTheme.equals(lightTheme)) defaultTheme = R.style.LightTheme;
|
||||
|
@ -83,19 +146,13 @@ public class ThemeHelper {
|
|||
return defaultTheme;
|
||||
}
|
||||
|
||||
public static String getSelectedTheme(Context context) {
|
||||
String themeKey = context.getString(R.string.theme_key);
|
||||
String defaultTheme = context.getResources().getString(R.string.default_theme_value);
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme);
|
||||
}
|
||||
|
||||
@StyleRes
|
||||
public static int getSettingsThemeStyle(Context context) {
|
||||
String lightTheme = context.getResources().getString(R.string.light_theme_key);
|
||||
String darkTheme = context.getResources().getString(R.string.dark_theme_key);
|
||||
String blackTheme = context.getResources().getString(R.string.black_theme_key);
|
||||
|
||||
String selectedTheme = getSelectedTheme(context);
|
||||
String selectedTheme = getSelectedThemeString(context);
|
||||
|
||||
if (selectedTheme.equals(lightTheme)) return R.style.LightSettingsTheme;
|
||||
else if (selectedTheme.equals(blackTheme)) return R.style.BlackSettingsTheme;
|
||||
|
@ -113,4 +170,24 @@ public class ThemeHelper {
|
|||
a.recycle();
|
||||
return attributeResourceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a color from an attr styled according to the the context's theme.
|
||||
*/
|
||||
public static int resolveColorFromAttr(Context context, @AttrRes int attrColor) {
|
||||
final TypedValue value = new TypedValue();
|
||||
context.getTheme().resolveAttribute(attrColor, value, true);
|
||||
|
||||
if (value.resourceId != 0) {
|
||||
return ContextCompat.getColor(context, value.resourceId);
|
||||
}
|
||||
|
||||
return value.data;
|
||||
}
|
||||
|
||||
private static String getSelectedThemeString(Context context) {
|
||||
String themeKey = context.getString(R.string.theme_key);
|
||||
String defaultTheme = context.getResources().getString(R.string.default_theme_value);
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* CollapsibleView.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.IntDef;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.RequiresApi;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.schabi.newpipe.util.AnimationUtils;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
|
||||
import static java.lang.annotation.RetentionPolicy.SOURCE;
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
/**
|
||||
* A view that can be fully collapsed and expanded.
|
||||
*/
|
||||
public class CollapsibleView extends LinearLayout {
|
||||
private static final String TAG = CollapsibleView.class.getSimpleName();
|
||||
|
||||
public CollapsibleView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public CollapsibleView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public CollapsibleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
|
||||
public CollapsibleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Collapse/expand logic
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private static final int ANIMATION_DURATION = 420;
|
||||
public static final int COLLAPSED = 0, EXPANDED = 1;
|
||||
|
||||
@Retention(SOURCE)
|
||||
@IntDef({COLLAPSED, EXPANDED})
|
||||
public @interface ViewMode {}
|
||||
|
||||
@State @ViewMode int currentState = COLLAPSED;
|
||||
private boolean readyToChangeState;
|
||||
|
||||
private int targetHeight = -1;
|
||||
private ValueAnimator currentAnimator;
|
||||
private List<StateListener> listeners = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* This method recalculates the height of this view so it <b>must</b> be called when
|
||||
* some child changes (e.g. add new views, change text).
|
||||
*/
|
||||
public void ready() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, getDebugLogString("ready() called"));
|
||||
}
|
||||
|
||||
measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), MeasureSpec.UNSPECIFIED);
|
||||
targetHeight = getMeasuredHeight();
|
||||
|
||||
getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight;
|
||||
requestLayout();
|
||||
broadcastState();
|
||||
|
||||
readyToChangeState = true;
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, getDebugLogString("ready() *after* measuring"));
|
||||
}
|
||||
}
|
||||
|
||||
public void collapse() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, getDebugLogString("collapse() called"));
|
||||
}
|
||||
|
||||
if (!readyToChangeState) return;
|
||||
|
||||
final int height = getHeight();
|
||||
if (height == 0) {
|
||||
setCurrentState(COLLAPSED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel();
|
||||
currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, 0);
|
||||
|
||||
setCurrentState(COLLAPSED);
|
||||
}
|
||||
|
||||
public void expand() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, getDebugLogString("expand() called"));
|
||||
}
|
||||
|
||||
if (!readyToChangeState) return;
|
||||
|
||||
final int height = getHeight();
|
||||
if (height == this.targetHeight) {
|
||||
setCurrentState(EXPANDED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel();
|
||||
currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight);
|
||||
setCurrentState(EXPANDED);
|
||||
}
|
||||
|
||||
public void switchState() {
|
||||
if (!readyToChangeState) return;
|
||||
|
||||
if (currentState == COLLAPSED) {
|
||||
expand();
|
||||
} else {
|
||||
collapse();
|
||||
}
|
||||
}
|
||||
|
||||
@ViewMode
|
||||
public int getCurrentState() {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
public void setCurrentState(@ViewMode int currentState) {
|
||||
this.currentState = currentState;
|
||||
broadcastState();
|
||||
}
|
||||
|
||||
public void broadcastState() {
|
||||
for (StateListener listener : listeners) {
|
||||
listener.onStateChanged(currentState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener which will be listening for changes in this view (i.e. collapsed or expanded).
|
||||
*/
|
||||
public void addListener(final StateListener listener) {
|
||||
if (listeners.contains(listener)) {
|
||||
throw new IllegalStateException("Trying to add the same listener multiple times");
|
||||
}
|
||||
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a listener so it doesn't receive more state changes.
|
||||
*/
|
||||
public void removeListener(final StateListener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple interface used for listening state changes of the {@link CollapsibleView}.
|
||||
*/
|
||||
public interface StateListener {
|
||||
/**
|
||||
* Called when the state changes.
|
||||
*
|
||||
* @param newState the state that the {@link CollapsibleView} transitioned to,<br/>
|
||||
* it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED}
|
||||
*/
|
||||
void onStateChanged(@ViewMode int newState);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// State Saving
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Parcelable onSaveInstanceState() {
|
||||
return Icepick.saveInstanceState(this, super.onSaveInstanceState());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestoreInstanceState(Parcelable state) {
|
||||
super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state));
|
||||
|
||||
ready();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Internal
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public String getDebugLogString(String description) {
|
||||
return String.format("%-100s → %s",
|
||||
description, "readyToChangeState = [" + readyToChangeState + "], currentState = [" + currentState + "], targetHeight = [" + targetHeight + "]," +
|
||||
" mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "]" +
|
||||
" W x H = [" + getWidth() + "x" + getHeight() + "]");
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 337 B |
After Width: | Height: | Size: 345 B |
After Width: | Height: | Size: 163 B |
After Width: | Height: | Size: 172 B |
After Width: | Height: | Size: 240 B |
After Width: | Height: | Size: 247 B |
After Width: | Height: | Size: 226 B |
After Width: | Height: | Size: 235 B |
After Width: | Height: | Size: 128 B |
After Width: | Height: | Size: 132 B |
After Width: | Height: | Size: 167 B |
After Width: | Height: | Size: 168 B |
After Width: | Height: | Size: 386 B |
After Width: | Height: | Size: 405 B |
After Width: | Height: | Size: 186 B |
After Width: | Height: | Size: 202 B |
After Width: | Height: | Size: 264 B |
After Width: | Height: | Size: 273 B |
After Width: | Height: | Size: 561 B |
After Width: | Height: | Size: 589 B |
After Width: | Height: | Size: 230 B |
After Width: | Height: | Size: 252 B |
After Width: | Height: | Size: 368 B |
After Width: | Height: | Size: 391 B |
After Width: | Height: | Size: 733 B |
After Width: | Height: | Size: 770 B |
After Width: | Height: | Size: 302 B |
After Width: | Height: | Size: 328 B |
After Width: | Height: | Size: 477 B |
After Width: | Height: | Size: 504 B |
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/info_text_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_above="@+id/inputs_panel"
|
||||
android:layout_alignParentTop="true"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="16dp"
|
||||
android:scrollbars="vertical"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
tools:text="@string/import_soundcloud_instructions"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/inputs_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/input_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:inputType="text"
|
||||
android:visibility="gone"
|
||||
tools:hint="@string/import_soundcloud_instructions_hint"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/input_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
tools:text="@string/import_title"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_alignParentTop="true"
|
||||
android:background="?attr/toolbar_shadow_drawable"/>
|
||||
|
||||
</RelativeLayout>
|
|
@ -9,10 +9,12 @@
|
|||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/items_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="LinearLayoutManager"
|
||||
tools:listitem="@layout/list_channel_item"/>
|
||||
android:visibility="gone"
|
||||
tools:listitem="@layout/list_channel_item"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<!--ERROR PANEL-->
|
||||
<include
|
||||
|
@ -21,6 +23,7 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_below="@id/items_list"
|
||||
android:layout_marginTop="50dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"/>
|
||||
|
@ -31,6 +34,7 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:layout_below="@id/items_list"
|
||||
android:layout_marginTop="50dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"/>
|
||||
|
|
|
@ -1,43 +1,139 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/whatsNewIcon"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:src="?attr/rss"
|
||||
tools:ignore="ContentDescription,RtlHardcoded"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/whatsNew"
|
||||
<LinearLayout
|
||||
android:id="@+id/whats_new"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_toRightOf="@+id/whatsNewIcon"
|
||||
android:gravity="left|center"
|
||||
android:text="@string/fragment_whats_new"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="RtlHardcoded"/>
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/whats_new_icon"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:src="?attr/rss"
|
||||
tools:ignore="ContentDescription,RtlHardcoded"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="left|center"
|
||||
android:text="@string/fragment_whats_new"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="RtlHardcoded"/>
|
||||
</LinearLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/import_export"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/import_export_icon"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:src="?attr/ic_import_export"
|
||||
tools:ignore="ContentDescription,RtlHardcoded"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_toRightOf="@+id/import_export_icon"
|
||||
android:layout_toLeftOf="@+id/import_export_expand_icon"
|
||||
android:gravity="left|center"
|
||||
android:text="@string/import_export_title"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="RtlHardcoded"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/import_export_expand_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginRight="24dp"
|
||||
android:src="?attr/expand"
|
||||
tools:ignore="ContentDescription,RtlHardcoded"/>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<org.schabi.newpipe.views.CollapsibleView
|
||||
android:id="@+id/import_export_options"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
tools:ignore="RtlSymmetry">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/import_from_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/subscription_import_export_item_height"
|
||||
android:gravity="left|center"
|
||||
android:maxLines="1"
|
||||
android:paddingLeft="72dp"
|
||||
android:text="@string/import_from"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="13sp"
|
||||
tools:ignore="RtlHardcoded"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/import_from_options"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="72dp"
|
||||
android:layout_marginStart="72dp"
|
||||
android:orientation="vertical"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/export_to_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/subscription_import_export_item_height"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="left|center"
|
||||
android:maxLines="1"
|
||||
android:paddingLeft="72dp"
|
||||
android:text="@string/export_to"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="13sp"
|
||||
tools:ignore="RtlHardcoded"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/export_to_options"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="72dp"
|
||||
android:layout_marginStart="72dp"
|
||||
android:orientation="vertical"/>
|
||||
</org.schabi.newpipe.views.CollapsibleView>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_below="@+id/whatsNew"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="?attr/separator_color"/>
|
||||
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@android:id/icon1"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="@dimen/subscription_import_export_item_icon_size"
|
||||
android:layout_marginTop="@dimen/subscription_import_export_item_icon_margin"
|
||||
android:layout_marginBottom="@dimen/subscription_import_export_item_icon_margin"
|
||||
android:scaleType="fitCenter"
|
||||
tools:ignore="ContentDescription,RtlHardcoded"
|
||||
tools:src="@drawable/place_holder_youtube"/>
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/text1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/subscription_import_export_item_height"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:paddingLeft="6dp"
|
||||
android:paddingRight="6dp"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textSize="13sp"
|
||||
tools:text="@string/youtube"/>
|
||||
</LinearLayout>
|
|
@ -5,6 +5,7 @@
|
|||
<item
|
||||
android:id="@+id/action_search"
|
||||
android:icon="?attr/search"
|
||||
android:orderInCategory="1"
|
||||
android:title="@string/search"
|
||||
app:showAsAction="always"/>
|
||||
</menu>
|
|
@ -46,4 +46,6 @@
|
|||
<!-- Elements Size -->
|
||||
<dimen name="playlist_item_thumbnail_stream_count_width">70dp</dimen>
|
||||
|
||||
<!-- File picker dimensions -->
|
||||
<dimen name="file_picker_items_text_size">16sp</dimen>
|
||||
</resources>
|
|
@ -32,6 +32,9 @@
|
|||
<attr name="ic_bookmark" format="reference"/>
|
||||
<attr name="ic_playlist_add" format="reference"/>
|
||||
<attr name="ic_playlist_check" format="reference"/>
|
||||
<attr name="ic_import_export" format="reference"/>
|
||||
<attr name="ic_save" format="reference"/>
|
||||
<attr name="ic_backup" format="reference"/>
|
||||
|
||||
<!-- Can't refer to colors directly into drawable's xml-->
|
||||
<attr name="toolbar_shadow_drawable" format="reference"/>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
<!-- Light Theme -->
|
||||
<color name="light_background_color">#EEEEEE</color>
|
||||
<color name="light_dialog_background_color">#EEEEEE</color>
|
||||
<color name="light_settings_accent_color">#e53935</color>
|
||||
<color name="light_separator_color">#32000000</color>
|
||||
<color name="light_ripple_color">#48868686</color>
|
||||
|
@ -16,6 +17,7 @@
|
|||
|
||||
<!-- Dark Theme -->
|
||||
<color name="dark_background_color">#222222</color>
|
||||
<color name="dark_dialog_background_color">#424242</color>
|
||||
<color name="dark_settings_accent_color">#ff5252</color>
|
||||
<color name="dark_separator_color">#0affffff</color>
|
||||
<color name="dark_ripple_color">#48ffffff</color>
|
||||
|
|
|
@ -25,6 +25,11 @@
|
|||
<!-- Miscellaneous -->
|
||||
<dimen name="popup_default_width">180dp</dimen>
|
||||
<dimen name="popup_minimum_width">150dp</dimen>
|
||||
|
||||
<dimen name="subscription_import_export_item_height">42dp</dimen>
|
||||
<dimen name="subscription_import_export_item_icon_size">24dp</dimen>
|
||||
<!-- (item_height - item_icon_size) / 2-->
|
||||
<dimen name="subscription_import_export_item_icon_margin">9dp</dimen>
|
||||
<!-- Video Item Detail View Dimensions-->
|
||||
<!-- Text Size -->
|
||||
<dimen name="video_item_detail_title_text_size">15sp</dimen>
|
||||
|
@ -75,4 +80,7 @@
|
|||
|
||||
<!-- Kiosk view Dimensions-->
|
||||
<dimen name="kiosk_title_text_size">30sp</dimen>
|
||||
|
||||
<!-- File picker dimensions -->
|
||||
<dimen name="file_picker_items_text_size">14sp</dimen>
|
||||
</resources>
|
||||
|
|
|
@ -132,6 +132,7 @@
|
|||
<string name="play_all">Play All</string>
|
||||
<string name="always">Always</string>
|
||||
<string name="just_once">Just Once</string>
|
||||
<string name="file">File</string>
|
||||
|
||||
<string name="notification_channel_id" translatable="false">newpipe</string>
|
||||
<string name="notification_channel_name">NewPipe Notification</string>
|
||||
|
@ -169,6 +170,11 @@
|
|||
<string name="invalid_url_toast">Invalid URL</string>
|
||||
<string name="video_streams_empty">No video streams found</string>
|
||||
<string name="audio_streams_empty">No audio streams found</string>
|
||||
<string name="invalid_directory">Invalid directory</string>
|
||||
<string name="invalid_source">Invalid file/content source</string>
|
||||
<string name="invalid_file">File doesn\'t exist or insufficient permission to read or write to it</string>
|
||||
<string name="file_name_empty_error">File name cannot be empty</string>
|
||||
<string name="error_occurred_detail">An error occurred: %1$s</string>
|
||||
|
||||
<!-- error activity -->
|
||||
<string name="sorry_string">Sorry, that should not have happened.</string>
|
||||
|
@ -427,4 +433,24 @@
|
|||
<string name="enable_disposed_exceptions_title">Report Out-of-Lifecycle Errors</string>
|
||||
<string name="enable_disposed_exceptions_summary">Force reporting of undeliverable Rx exceptions occurring outside of fragment or activity lifecycle after dispose</string>
|
||||
|
||||
<!-- Subscriptions import/export -->
|
||||
<string name="import_export_title">Import/Export</string>
|
||||
<string name="import_title">Import</string>
|
||||
<string name="import_from">Import from</string>
|
||||
<string name="export_to">Export to</string>
|
||||
|
||||
<string name="import_ongoing">Importing…</string>
|
||||
<string name="export_ongoing">Exporting…</string>
|
||||
|
||||
<string name="import_file_title">Import file</string>
|
||||
<string name="previous_export">Previous export</string>
|
||||
|
||||
<string name="subscriptions_import_unsuccessful">Subscriptions import failed</string>
|
||||
<string name="subscriptions_export_unsuccessful">Subscriptions export failed</string>
|
||||
|
||||
<string name="import_youtube_instructions">To import your YouTube subscriptions you will need the export file, which can be downloaded following these instructions:\n\n1. Go to this url: %1$s\n2. Log in to your account when asked\n3. A download should start (that\'s the export file) </string>
|
||||
<string name="import_soundcloud_instructions">To import your SoundCloud followings you have to know your profile url or id. If you do, just type either of them in the input below and you\'re ready to go.\n\nIf you don\'t, you can follow these steps:\n\n1. Enable \"desktop mode\" in some browser (the site is not available for mobile devices)\n2. Go to this url: %1$s\n3. Log in to your account when asked\n4. Copy the url that you were redirected to (that\'s your profile url)</string>
|
||||
<string name="import_soundcloud_instructions_hint">yourid, soundcloud.com/yourid</string>
|
||||
|
||||
<string name="import_network_expensive_warning">Keep in mind that this operation can be network expensive.\n\nDo you want to continue?</string>
|
||||
</resources>
|
||||
|
|
|
@ -48,6 +48,9 @@
|
|||
<item name="ic_bookmark">@drawable/ic_bookmark_black_24dp</item>
|
||||
<item name="ic_playlist_add">@drawable/ic_playlist_add_black_24dp</item>
|
||||
<item name="ic_playlist_check">@drawable/ic_playlist_add_check_black_24dp</item>
|
||||
<item name="ic_import_export">@drawable/ic_import_export_black_24dp</item>
|
||||
<item name="ic_save">@drawable/ic_save_black_24dp</item>
|
||||
<item name="ic_backup">@drawable/ic_backup_black_24dp</item>
|
||||
|
||||
<item name="separator_color">@color/light_separator_color</item>
|
||||
<item name="contrast_background_color">@color/light_contrast_background_color</item>
|
||||
|
@ -102,6 +105,9 @@
|
|||
<item name="ic_bookmark">@drawable/ic_bookmark_white_24dp</item>
|
||||
<item name="ic_playlist_add">@drawable/ic_playlist_add_white_24dp</item>
|
||||
<item name="ic_playlist_check">@drawable/ic_playlist_add_check_white_24dp</item>
|
||||
<item name="ic_import_export">@drawable/ic_import_export_white_24dp</item>
|
||||
<item name="ic_save">@drawable/ic_save_white_24dp</item>
|
||||
<item name="ic_backup">@drawable/ic_backup_white_24dp</item>
|
||||
|
||||
<item name="separator_color">@color/dark_separator_color</item>
|
||||
<item name="contrast_background_color">@color/dark_contrast_background_color</item>
|
||||
|
@ -130,6 +136,23 @@
|
|||
<item name="android:windowAnimationStyle">@style/SwitchAnimation</item>
|
||||
</style>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<style name="LightDialogTheme" parent="Theme.AppCompat.Light.Dialog">
|
||||
<item name="colorPrimary">@color/light_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/light_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/light_youtube_accent_color</item>
|
||||
<item name="android:windowBackground">@color/light_dialog_background_color</item>
|
||||
<item name="windowBackground">@color/light_dialog_background_color</item>
|
||||
</style>
|
||||
|
||||
<style name="DarkDialogTheme" parent="Theme.AppCompat.Dialog">
|
||||
<item name="colorPrimary">@color/dark_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/dark_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/dark_youtube_accent_color</item>
|
||||
<item name="android:windowBackground">@color/dark_dialog_background_color</item>
|
||||
<item name="windowBackground">@color/dark_dialog_background_color</item>
|
||||
</style>
|
||||
|
||||
<!-- Settings -->
|
||||
<style name="LightSettingsTheme" parent="LightTheme">
|
||||
<item name="colorAccent">@color/light_settings_accent_color</item>
|
||||
|
|
|
@ -9,47 +9,35 @@
|
|||
|
||||
|
||||
<!--File picker styles-->
|
||||
|
||||
<style name="FilePickerThemeLight" parent="NNF_BaseTheme.Light">
|
||||
<item name="colorPrimary">@color/light_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/light_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/light_youtube_accent_color</item>
|
||||
<item name="android:background">@color/light_background_color</item>
|
||||
<item name="colorAccent">@color/light_settings_accent_color</item>
|
||||
<item name="android:windowBackground">@color/light_background_color</item>
|
||||
<item name="nnf_separator_color">@color/light_separator_color</item>
|
||||
|
||||
<item name="alertDialogTheme">@style/FilePickerAlertDialogThemeLight</item>
|
||||
<item name="nnf_toolbarTheme">@style/FilePickerToolbarLight</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerAlertDialogThemeLight" parent="Theme.AppCompat.Dialog.Alert">
|
||||
<item name="colorPrimary">@color/light_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/light_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/light_youtube_accent_color</item>
|
||||
<item name="colorAccent">@color/light_settings_accent_color</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerToolbarLight" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
|
||||
<item name="android:background">@color/light_youtube_primary_color</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerThemeDark" parent="FilePickerThemeLight">
|
||||
<style name="FilePickerThemeDark" parent="NNF_BaseTheme">
|
||||
<item name="colorPrimary">@color/dark_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/dark_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/dark_youtube_accent_color</item>
|
||||
<item name="android:background">@color/dark_background_color</item>
|
||||
<item name="android:textColorPrimary">@color/dark_youtube_accent_color</item>
|
||||
<item name="colorAccent">@color/dark_settings_accent_color</item>
|
||||
<item name="android:windowBackground">@color/dark_background_color</item>
|
||||
<item name="nnf_separator_color">@color/black_separator_color</item>
|
||||
|
||||
<item name="alertDialogTheme">@style/FilePickerAlertDialogThemeDark</item>
|
||||
<item name="nnf_toolbarTheme">@style/FilePickerToolbarDark</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerAlertDialogThemeDark" parent="Theme.AppCompat.Dialog.Alert">
|
||||
<item name="colorPrimary">@color/dark_youtube_primary_color</item>
|
||||
<item name="colorPrimaryDark">@color/dark_youtube_dark_color</item>
|
||||
<item name="colorAccent">@color/dark_youtube_accent_color</item>
|
||||
</style>
|
||||
|
||||
<style name="FilePickerToolbarDark" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
|
||||
<item name="android:background">@color/dark_youtube_primary_color</item>
|
||||
<item name="colorAccent">@color/dark_settings_accent_color</item>
|
||||
</style>
|
||||
</resources>
|
|
@ -0,0 +1,117 @@
|
|||
package org.schabi.newpipe.subscription.services;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.subscription.ImportExportJsonHelper;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/**
|
||||
* @see ImportExportJsonHelper
|
||||
*/
|
||||
public class ImportExportJsonHelperTest {
|
||||
@Test
|
||||
public void testEmptySource() throws Exception {
|
||||
String emptySource = "{\"app_version\":\"0.11.6\",\"app_version_int\": 47,\"subscriptions\":[]}";
|
||||
|
||||
List<SubscriptionItem> items = ImportExportJsonHelper.readFrom(new ByteArrayInputStream(emptySource.getBytes("UTF-8")), null);
|
||||
assertTrue(items.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidSource() throws Exception {
|
||||
List<String> invalidList = Arrays.asList(
|
||||
"{}",
|
||||
"",
|
||||
null,
|
||||
"gibberish");
|
||||
|
||||
for (String invalidContent : invalidList) {
|
||||
try {
|
||||
if (invalidContent != null) {
|
||||
byte[] bytes = invalidContent.getBytes("UTF-8");
|
||||
ImportExportJsonHelper.readFrom((new ByteArrayInputStream(bytes)), null);
|
||||
} else {
|
||||
ImportExportJsonHelper.readFrom(null, null);
|
||||
}
|
||||
|
||||
fail("didn't throw exception");
|
||||
} catch (Exception e) {
|
||||
boolean isExpectedException = e instanceof SubscriptionExtractor.InvalidSourceException;
|
||||
assertTrue("\"" + e.getClass().getSimpleName() + "\" is not the expected exception", isExpectedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ultimateTest() throws Exception {
|
||||
// Read from file
|
||||
final List<SubscriptionItem> itemsFromFile = readFromFile();
|
||||
|
||||
// Test writing to an output
|
||||
final String jsonOut = testWriteTo(itemsFromFile);
|
||||
|
||||
// Read again
|
||||
final List<SubscriptionItem> itemsSecondRead = readFromWriteTo(jsonOut);
|
||||
|
||||
// Check if both lists have the exact same items
|
||||
if (itemsFromFile.size() != itemsSecondRead.size()) {
|
||||
fail("The list of items were different from each other");
|
||||
}
|
||||
|
||||
for (int i = 0; i < itemsFromFile.size(); i++) {
|
||||
final SubscriptionItem item1 = itemsFromFile.get(i);
|
||||
final SubscriptionItem item2 = itemsSecondRead.get(i);
|
||||
|
||||
final boolean equals = item1.getServiceId() == item2.getServiceId() &&
|
||||
item1.getUrl().equals(item2.getUrl()) &&
|
||||
item1.getName().equals(item2.getName());
|
||||
|
||||
if (!equals) {
|
||||
fail("The list of items were different from each other");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SubscriptionItem> readFromFile() throws Exception {
|
||||
final InputStream inputStream = getClass().getClassLoader().getResourceAsStream("import_export_test.json");
|
||||
final List<SubscriptionItem> itemsFromFile = ImportExportJsonHelper.readFrom(inputStream, null);
|
||||
|
||||
if (itemsFromFile == null || itemsFromFile.isEmpty()) {
|
||||
fail("ImportExportJsonHelper.readFrom(input) returned a null or empty list");
|
||||
}
|
||||
|
||||
return itemsFromFile;
|
||||
}
|
||||
|
||||
private String testWriteTo(List<SubscriptionItem> itemsFromFile) throws Exception {
|
||||
final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ImportExportJsonHelper.writeTo(itemsFromFile, out, null);
|
||||
final String jsonOut = out.toString("UTF-8");
|
||||
|
||||
if (jsonOut.isEmpty()) {
|
||||
fail("JSON returned by writeTo was empty");
|
||||
}
|
||||
|
||||
return jsonOut;
|
||||
}
|
||||
|
||||
private List<SubscriptionItem> readFromWriteTo(String jsonOut) throws Exception {
|
||||
final ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonOut.getBytes("UTF-8"));
|
||||
final List<SubscriptionItem> secondReadItems = ImportExportJsonHelper.readFrom(inputStream, null);
|
||||
|
||||
if (secondReadItems == null || secondReadItems.isEmpty()) {
|
||||
fail("second call to readFrom returned an empty list");
|
||||
}
|
||||
|
||||
return secondReadItems;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"app_version": "0.11.6",
|
||||
"app_version_int": 47,
|
||||
"subscriptions": [
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
"name": "Kurzgesagt \u2013 In a Nutshell"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCEOXxzW2vU0P-0THehuIIeg",
|
||||
"name": "CaptainDisillusion"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCAuUUnT6oDeKwE6v1NGQxug",
|
||||
"name": "TED"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCfIXdjDQH9Fau7y99_Orpjw",
|
||||
"name": "Gorillaz"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCJ0-OtVpF0wOKEqT2Z1HEtA",
|
||||
"name": "ElectroBOOM"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
"name": "ⓤⓝⓘⓒⓞⓓⓔ"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
"name": "中文"
|
||||
},
|
||||
{
|
||||
"service_id": 0,
|
||||
"url": "https://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
"name": "हिंदी"
|
||||
}
|
||||
]
|
||||
}
|