Merge branch 'sictiru'

* sictiru: (31 commits)
  Android v13.1.0
  #1811 Verify error handling and show loading indicator to the user when adding a feed.
  Fix broken swipe to refresh dependency
  Use plugin information from buildSrc
  Use dependencies and constants from buildSrc
  Add buildSrc for dependency management
  Convert Groovy to Kotlin. Update dependencies
  Convert Groovy to Kotlin
  Update sub service to cancel job when requested. Execute on the IO dispatcher.
  Await for sub service termination
  Use coroutines in sub services
  Update proguard rules
  Enable nonFinalResIds
  Enable non transitive R classes
  Update java version for the android GHA
  Upgrade dependencies
  #1794 Add auto fill hints for easier login
  Import
  Remove context from blur db helper
  Intel dialog row tweak
  ...
This commit is contained in:
Samuel Clay 2023-08-20 16:03:15 -04:00
commit 25a5929f56
81 changed files with 2090 additions and 708 deletions

View file

@ -19,7 +19,7 @@ jobs:
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
java-version: '17'
- name: Unit Test
run: ./gradlew -Pci --console=plain :app:testDebugUnitTest

View file

@ -1,52 +0,0 @@
plugins {
id 'com.android.test'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.newsblur.benchmark'
compileSdk 33
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
minSdk 23
targetSdk 33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
// This benchmark buildType is used for benchmarking, and should function like your
// release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing.
benchmark {
debuggable = true
signingConfig = debug.signingConfig
matchingFallbacks = ["release"]
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation 'androidx.test.ext:junit:1.1.3'
implementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'androidx.test.uiautomator:uiautomator:2.2.0'
implementation 'androidx.benchmark:benchmark-macro-junit4:1.1.0'
}
androidComponents {
beforeVariants(selector().all()) {
enabled = buildType == "benchmark"
}
}

View file

@ -0,0 +1,49 @@
plugins {
id(Plugins.androidTest)
kotlin(Plugins.kotlinAndroid)
}
android {
namespace = Const.namespaceBenchmark
compileSdk = Config.compileSdk
compileOptions {
sourceCompatibility = Config.javaVersion
targetCompatibility = Config.javaVersion
}
defaultConfig {
minSdk = Config.minSdk
targetSdk = Config.targetSdk
testInstrumentationRunner = Config.androidTestInstrumentation
}
buildTypes {
// This benchmark buildType is used for benchmarking, and should function like your
// release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing.
maybeCreate(Const.benchmark)
getByName(Const.benchmark) {
isDebuggable = true
signingConfig = signingConfigs.getByName(Const.debug)
matchingFallbacks += listOf(Const.release)
}
}
targetProjectPath = ":app"
experimentalProperties[Const.selfInstrumenting] = true
}
dependencies {
implementation(Dependencies.junitExt)
implementation(Dependencies.espressoCore)
implementation(Dependencies.uiAutomator)
implementation(Dependencies.benchmarkMacroJunit4)
}
androidComponents {
beforeVariants(selector().all()) {
it.enabled = it.buildType == Const.benchmark
}
}

View file

@ -1,74 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.kapt'
id 'com.google.dagger.hilt.android'
}
android {
namespace 'com.newsblur'
compileSdk 33
defaultConfig {
applicationId "com.newsblur"
minSdk 23
targetSdk 33
versionCode 212
versionName "13.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
minifyEnabled false
shrinkResources false
}
benchmark {
signingConfig signingConfigs.debug
matchingFallbacks = ['release']
debuggable false
proguardFiles('benchmark-rules.pro')
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
resources.excludes.add("META-INF/*")
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures.viewBinding = true
}
dependencies {
implementation 'androidx.fragment:fragment-ktx:1.5.5'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.google.code.gson:gson:2.10'
implementation 'com.android.billingclient:billing:5.1.0'
implementation 'com.google.android.play:core:1.10.3'
implementation "com.google.android.material:material:1.7.0"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.browser:browser:1.4.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "com.google.dagger:hilt-android:2.44.2"
kapt "com.google.dagger:hilt-compiler:2.44.2"
implementation "androidx.profileinstaller:profileinstaller:1.2.2"
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

View file

@ -0,0 +1,75 @@
plugins {
id(Plugins.androidApplication)
kotlin(Plugins.kotlinAndroid)
kotlin(Plugins.kotlinKapt)
id(Plugins.hiltAndroid)
}
android {
namespace = Const.namespace
compileSdk = Config.compileSdk
defaultConfig {
applicationId = Const.namespace
minSdk = Config.minSdk
targetSdk = Config.targetSdk
versionCode = Config.versionCode
versionName = Config.versionName
testInstrumentationRunner = Config.androidTestInstrumentation
}
buildTypes {
getByName(Const.debug) {
isMinifyEnabled = false
isShrinkResources = false
}
maybeCreate(Const.benchmark)
getByName(Const.benchmark) {
signingConfig = signingConfigs.getByName(Const.debug)
matchingFallbacks += listOf(Const.release)
isDebuggable = false
proguardFiles(Const.benchmarkProguard)
}
getByName(Const.release) {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile(Const.defaultProguard), Const.appProguard)
}
}
packaging {
resources.excludes.add("META-INF/*")
}
compileOptions {
sourceCompatibility = Config.javaVersion
targetCompatibility = Config.javaVersion
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(Dependencies.fragment)
implementation(Dependencies.recyclerView)
implementation(Dependencies.swipeRefreshLayout)
implementation(Dependencies.okHttp)
implementation(Dependencies.gson)
implementation(Dependencies.billing)
implementation(Dependencies.playCore)
implementation(Dependencies.material)
implementation(Dependencies.preference)
implementation(Dependencies.browser)
implementation(Dependencies.lifecycleRuntime)
implementation(Dependencies.lifecycleProcess)
implementation(Dependencies.splashScreen)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltCompiler)
implementation(Dependencies.profileInstaller)
testImplementation(Dependencies.junit)
testImplementation(Dependencies.mockk)
androidTestImplementation(Dependencies.junitExt)
androidTestImplementation(Dependencies.espressoCore)
}

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@ -41,3 +41,7 @@
# can be commented out to help diagnose shrinkage errors.
-dontwarn **
-dontnote **
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep, allowobfuscation, allowshrinking class com.google.gson.reflect.TypeToken
-keep, allowobfuscation, allowshrinking class * extends com.google.gson.reflect.TypeToken

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.newsblur">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

View file

@ -101,44 +101,44 @@ abstract public class FeedChooser extends NbActivity {
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
case R.id.menu_sort_order_ascending:
replaceListOrderFilter(ListOrderFilter.ASCENDING);
return true;
case R.id.menu_sort_order_descending:
replaceListOrderFilter(ListOrderFilter.DESCENDING);
return true;
case R.id.menu_sort_by_name:
replaceFeedOrderFilter(FeedOrderFilter.NAME);
return true;
case R.id.menu_sort_by_subs:
replaceFeedOrderFilter(FeedOrderFilter.SUBSCRIBERS);
return true;
case R.id.menu_sort_by_recent_story:
replaceFeedOrderFilter(FeedOrderFilter.RECENT_STORY);
return true;
case R.id.menu_sort_by_stories_month:
replaceFeedOrderFilter(FeedOrderFilter.STORIES_MONTH);
return true;
case R.id.menu_sort_by_number_opens:
replaceFeedOrderFilter(FeedOrderFilter.OPENS);
return true;
case R.id.menu_folder_view_nested:
replaceFolderView(FolderViewFilter.NESTED);
return true;
case R.id.menu_folder_view_flat:
replaceFolderView(FolderViewFilter.FLAT);
return true;
case R.id.menu_widget_background_default:
setWidgetBackground(WidgetBackground.DEFAULT);
return true;
case R.id.menu_widget_background_transparent:
setWidgetBackground(WidgetBackground.TRANSPARENT);
default:
return super.onOptionsItemSelected(item);
if (item.getItemId() == android.R.id.home) {
finish();
return true;
} else if (item.getItemId() == R.id.menu_sort_order_ascending) {
replaceListOrderFilter(ListOrderFilter.ASCENDING);
return true;
} else if (item.getItemId() == R.id.menu_sort_order_descending) {
replaceListOrderFilter(ListOrderFilter.DESCENDING);
return true;
} else if (item.getItemId() == R.id.menu_sort_by_name) {
replaceFeedOrderFilter(FeedOrderFilter.NAME);
return true;
} else if (item.getItemId() == R.id.menu_sort_by_subs) {
replaceFeedOrderFilter(FeedOrderFilter.SUBSCRIBERS);
return true;
} else if (item.getItemId() == R.id.menu_sort_by_recent_story) {
replaceFeedOrderFilter(FeedOrderFilter.RECENT_STORY);
return true;
} else if (item.getItemId() == R.id.menu_sort_by_stories_month) {
replaceFeedOrderFilter(FeedOrderFilter.STORIES_MONTH);
return true;
} else if (item.getItemId() == R.id.menu_sort_by_number_opens) {
replaceFeedOrderFilter(FeedOrderFilter.OPENS);
return true;
} else if (item.getItemId() == R.id.menu_folder_view_nested) {
replaceFolderView(FolderViewFilter.NESTED);
return true;
} else if (item.getItemId() == R.id.menu_folder_view_flat) {
replaceFolderView(FolderViewFilter.FLAT);
return true;
} else if (item.getItemId() == R.id.menu_widget_background_default) {
setWidgetBackground(WidgetBackground.DEFAULT);
return true;
} else if (item.getItemId() == R.id.menu_widget_background_transparent) {
setWidgetBackground(WidgetBackground.TRANSPARENT);
return true;
} else {
return super.onOptionsItemSelected(item);
}
}

View file

@ -81,7 +81,7 @@ public abstract class ItemsList extends NbActivity implements ReadingActionListe
// this is not strictly necessary, since our first refresh with the fs will swap in
// the correct session, but that can be delayed by sync backup, so we try here to
// reduce UI lag, or in case somehow we got redisplayed in a zero-story state
feedUtils.prepareReadingSession(fs, false);
feedUtils.prepareReadingSession(this, fs, false);
if (getIntent().getBooleanExtra(EXTRA_WIDGET_STORY, false)) {
String hash = (String) getIntent().getSerializableExtra(EXTRA_STORY_HASH);
UIUtils.startReadingActivity(fs, hash, this);
@ -206,7 +206,7 @@ public abstract class ItemsList extends NbActivity implements ReadingActionListe
if (session != null) {
// set the next session on the parent activity
fs = session.getFeedSet();
feedUtils.prepareReadingSession(fs, false);
feedUtils.prepareReadingSession(this, fs, false);
triggerSync();
// set the next session on the child activity
@ -248,7 +248,7 @@ public abstract class ItemsList extends NbActivity implements ReadingActionListe
String oldQuery = fs.getSearchQuery();
fs.setSearchQuery(q);
if (!TextUtils.equals(q, oldQuery)) {
feedUtils.prepareReadingSession(fs, true);
feedUtils.prepareReadingSession(this, fs, true);
triggerSync();
itemSetFragment.resetEmptyState();
itemSetFragment.hasUpdated();
@ -278,7 +278,7 @@ public abstract class ItemsList extends NbActivity implements ReadingActionListe
protected void restartReadingSession() {
NBSyncService.resetFetchState(fs);
feedUtils.prepareReadingSession(fs, true);
feedUtils.prepareReadingSession(this, fs, true);
triggerSync();
itemSetFragment.resetEmptyState();
itemSetFragment.hasUpdated();

View file

@ -10,10 +10,6 @@ import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Trace;
import android.preference.PreferenceManager;
import androidx.appcompat.widget.PopupMenu;
import androidx.fragment.app.FragmentManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
@ -22,13 +18,23 @@ import android.view.View;
import android.view.View.OnKeyListener;
import android.widget.AbsListView;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.PopupMenu;
import androidx.fragment.app.FragmentManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.newsblur.R;
import com.newsblur.database.BlurDatabaseHelper;
import com.newsblur.databinding.ActivityMainBinding;
import com.newsblur.delegate.MainContextMenuDelegate;
import com.newsblur.delegate.MainContextMenuDelegateImpl;
import com.newsblur.fragment.FeedIntelligenceSelectorFragment;
import com.newsblur.fragment.FeedSelectorFragment;
import com.newsblur.fragment.FeedsShortcutFragment;
import com.newsblur.fragment.FolderListFragment;
import com.newsblur.keyboard.KeyboardEvent;
import com.newsblur.keyboard.KeyboardListener;
import com.newsblur.keyboard.KeyboardManager;
import com.newsblur.service.BootReceiver;
import com.newsblur.service.NBSyncService;
import com.newsblur.util.AppConstants;
@ -45,7 +51,7 @@ import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
@AndroidEntryPoint
public class Main extends NbActivity implements StateChangedListener, SwipeRefreshLayout.OnRefreshListener, AbsListView.OnScrollListener, PopupMenu.OnMenuItemClickListener {
public class Main extends NbActivity implements StateChangedListener, SwipeRefreshLayout.OnRefreshListener, AbsListView.OnScrollListener, PopupMenu.OnMenuItemClickListener, KeyboardListener {
@Inject
FeedUtils feedUtils;
@ -55,10 +61,12 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
public static final String EXTRA_FORCE_SHOW_FEED_ID = "force_show_feed_id";
private FolderListFragment folderFeedList;
private FolderListFragment folderFeedList;
private FeedSelectorFragment feedSelectorFragment;
private boolean wasSwipeEnabled = false;
private ActivityMainBinding binding;
private MainContextMenuDelegate contextMenuDelegate;
private KeyboardManager keyboardManager;
@Override
public void onCreate(Bundle savedInstanceState) {
@ -69,7 +77,8 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
getWindow().setBackgroundDrawableResource(android.R.color.transparent);
binding = ActivityMainBinding.inflate(getLayoutInflater());
contextMenuDelegate = new MainContextMenuDelegateImpl(this, dbHelper);
setContentView(binding.getRoot());
keyboardManager = new KeyboardManager();
setContentView(binding.getRoot());
// set the status bar to an generic loading message when the activity is first created so
// that something is displayed while the service warms up
@ -82,7 +91,8 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
FragmentManager fragmentManager = getSupportFragmentManager();
folderFeedList = (FolderListFragment) fragmentManager.findFragmentByTag("folderFeedListFragment");
((FeedIntelligenceSelectorFragment) fragmentManager.findFragmentByTag("feedIntelligenceSelector")).setState(folderFeedList.currentState);
feedSelectorFragment = ((FeedSelectorFragment) fragmentManager.findFragmentByTag("feedIntelligenceSelector"));
feedSelectorFragment.setState(folderFeedList.currentState);
// make sure the interval sync is scheduled, since we are the root Activity
BootReceiver.scheduleSyncService(this);
@ -127,12 +137,8 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
// Check whether it's a shortcut intent
String shortcutExtra = getIntent().getStringExtra(ShortcutUtils.SHORTCUT_EXTRA);
if (shortcutExtra != null && shortcutExtra.startsWith(ShortcutUtils.SHORTCUT_ALL_STORIES)) {
Intent intent = new Intent(this, AllStoriesItemsList.class);
intent.putExtra(ItemsList.EXTRA_FEED_SET, FeedSet.allFeeds());
if (shortcutExtra.equals(ShortcutUtils.SHORTCUT_ALL_STORIES_SEARCH)) {
intent.putExtra(ItemsList.EXTRA_VISIBLE_SEARCH, true);
}
startActivity(intent);
boolean isAllStoriesSearch = shortcutExtra.equals(ShortcutUtils.SHORTCUT_ALL_STORIES_SEARCH);
openAllStories(isAllStoriesSearch);
}
Trace.endSection();
@ -177,10 +183,17 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
updateStatusIndicators();
folderFeedList.pushUnreadCounts();
folderFeedList.checkOpenFolderPreferences();
keyboardManager.addListener(this);
triggerSync();
}
@Override
@Override
protected void onPause() {
keyboardManager.removeListener();
super.onPause();
}
@Override
public void changedState(StateFilter state) {
if ( !( (state == StateFilter.ALL) ||
(state == StateFilter.SOME) ||
@ -211,7 +224,27 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
if ((updateType & UPDATE_METADATA) != 0) {
folderFeedList.hasUpdated();
}
}
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (KeyboardManager.hasHardwareKeyboard(this)) {
boolean isKnownKeyCode = keyboardManager.isKnownKeyCode(keyCode);
if (isKnownKeyCode) return true;
else return super.onKeyDown(keyCode, event);
}
return super.onKeyDown(keyCode, event);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (KeyboardManager.hasHardwareKeyboard(this)) {
boolean handledKeyCode = keyboardManager.onKeyUp(keyCode, event);
if (handledKeyCode) return true;
else return super.onKeyUp(keyCode, event);
}
return super.onKeyUp(keyCode, event);
}
public void updateUnreadCounts(int neutCount, int posiCount) {
binding.mainUnreadCountNeutText.setText(Integer.toString(neutCount));
@ -325,4 +358,58 @@ public class Main extends NbActivity implements StateChangedListener, SwipeRefre
}
folderFeedList.setSearchQuery(q);
}
private void openAllStories(boolean isAllStoriesSearch) {
Intent intent = new Intent(this, AllStoriesItemsList.class);
intent.putExtra(ItemsList.EXTRA_FEED_SET, FeedSet.allFeeds());
intent.putExtra(ItemsList.EXTRA_VISIBLE_SEARCH, isAllStoriesSearch);
startActivity(intent);
}
private void switchViewStateLeft() {
StateFilter currentState = folderFeedList.currentState;
if (currentState.equals(StateFilter.SAVED)) {
setAndNotifySelectorState(StateFilter.BEST, R.string.focused_stories);
} else if (currentState.equals(StateFilter.BEST)) {
setAndNotifySelectorState(StateFilter.SOME, R.string.unread_stories);
} else if (currentState.equals(StateFilter.SOME)) {
setAndNotifySelectorState(StateFilter.ALL, R.string.all_stories);
}
}
private void switchViewStateRight() {
StateFilter currentState = folderFeedList.currentState;
if (currentState.equals(StateFilter.ALL)) {
setAndNotifySelectorState(StateFilter.SOME, R.string.unread_stories);
} else if (currentState.equals(StateFilter.SOME)) {
setAndNotifySelectorState(StateFilter.BEST, R.string.focused_stories);
} else if (currentState.equals(StateFilter.BEST)) {
setAndNotifySelectorState(StateFilter.SAVED, R.string.saved_stories);
}
}
private void setAndNotifySelectorState(StateFilter state, @StringRes int notifyMsgRes) {
feedSelectorFragment.setState(state);
UIUtils.showSnackBar(binding.getRoot(), getString(notifyMsgRes));
}
private void showFeedShortcuts() {
FeedsShortcutFragment newFragment = new FeedsShortcutFragment();
newFragment.show(getSupportFragmentManager(), FeedsShortcutFragment.class.getName());
}
@Override
public void onKeyboardEvent(@NonNull KeyboardEvent event) {
if (event instanceof KeyboardEvent.AddFeed) {
onClickAddButton();
} else if (event instanceof KeyboardEvent.OpenAllStories) {
openAllStories(false);
} else if (event instanceof KeyboardEvent.SwitchViewLeft) {
switchViewStateLeft();
} else if (event instanceof KeyboardEvent.SwitchViewRight) {
switchViewStateRight();
} else if (event instanceof KeyboardEvent.Tutorial) {
showFeedShortcuts();
}
}
}

View file

@ -59,15 +59,14 @@ public class MuteConfig extends FeedChooser implements MuteConfigAdapter.FeedSta
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_mute_all:
setFeedsState(true);
return true;
case R.id.menu_mute_none:
setFeedsState(false);
return true;
default:
return super.onOptionsItemSelected(item);
if (item.getItemId() == R.id.menu_mute_all) {
setFeedsState(true);
return true;
} else if (item.getItemId() == R.id.menu_mute_none) {
setFeedsState(false);
return true;
} else {
return super.onOptionsItemSelected(item);
}
}

View file

@ -1,5 +1,6 @@
package com.newsblur.activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.database.Cursor
@ -22,7 +23,11 @@ import com.newsblur.databinding.ActivityReadingBinding
import com.newsblur.di.IconLoader
import com.newsblur.domain.Story
import com.newsblur.fragment.ReadingItemFragment
import com.newsblur.fragment.ReadingItemFragment.Companion.VERTICAL_SCROLL_DISTANCE_DP
import com.newsblur.fragment.ReadingPagerFragment
import com.newsblur.keyboard.KeyboardEvent
import com.newsblur.keyboard.KeyboardListener
import com.newsblur.keyboard.KeyboardManager
import com.newsblur.service.NBSyncReceiver.Companion.UPDATE_REBUILD
import com.newsblur.service.NBSyncReceiver.Companion.UPDATE_STATUS
import com.newsblur.service.NBSyncReceiver.Companion.UPDATE_STORY
@ -42,7 +47,7 @@ import javax.inject.Inject
import kotlin.math.abs
@AndroidEntryPoint
abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListener {
abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListener, KeyboardListener {
@Inject
lateinit var feedUtils: FeedUtils
@ -84,6 +89,7 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
private var isMultiWindowModeHack = false
private val pageHistory = mutableListOf<Story>()
private val keyboardManager = KeyboardManager()
private lateinit var volumeKeyNavigation: VolumeKeyNavigation
private lateinit var intelState: StateFilter
@ -136,7 +142,7 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
setupViews()
setupListeners()
setupObservers()
getActiveStoriesCursor(true)
getActiveStoriesCursor(this, true)
}
override fun onSaveInstanceState(outState: Bundle) {
@ -164,11 +170,13 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
// this is not strictly necessary, since our first refresh with the fs will swap in
// the correct session, but that can be delayed by sync backup, so we try here to
// reduce UI lag, or in case somehow we got redisplayed in a zero-story state
feedUtils.prepareReadingSession(fs, false)
feedUtils.prepareReadingSession(this, fs, false)
keyboardManager.addListener(this)
}
override fun onPause() {
super.onPause()
keyboardManager.removeListener()
if (isMultiWindowModeHack) {
isMultiWindowModeHack = false
} else {
@ -218,9 +226,10 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
}
}
private fun getActiveStoriesCursor(finishOnInvalidFs: Boolean = false) {
private fun getActiveStoriesCursor(context: Context, finishOnInvalidFs: Boolean = false) {
fs?.let {
storiesViewModel.getActiveStories(it)
val cursorFilters = CursorFilters(context, it)
storiesViewModel.getActiveStories(it, cursorFilters)
} ?: run {
if (finishOnInvalidFs) {
Log.e(this.javaClass.name, "can't create activity, no feedset ready")
@ -385,7 +394,7 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
}
}
if (updateType and UPDATE_STORY != 0) {
getActiveStoriesCursor()
getActiveStoriesCursor(this)
updateOverlayNav()
}
@ -737,6 +746,10 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
return if (isVolumeKeyNavigationEvent(keyCode)) {
processVolumeKeyNavigationEvent(keyCode)
true
} else if (KeyboardManager.hasHardwareKeyboard(this)) {
val isKnownKeyCode = keyboardManager.isKnownKeyCode(keyCode)
if (isKnownKeyCode) true
else super.onKeyDown(keyCode, event)
} else {
super.onKeyDown(keyCode, event)
}
@ -748,24 +761,32 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
private fun processVolumeKeyNavigationEvent(keyCode: Int) {
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN && volumeKeyNavigation == VolumeKeyNavigation.DOWN_NEXT ||
keyCode == KeyEvent.KEYCODE_VOLUME_UP && volumeKeyNavigation == VolumeKeyNavigation.UP_NEXT) {
if (pager == null) return
val nextPosition = pager!!.currentItem + 1
if (nextPosition < readingAdapter!!.count) {
try {
pager!!.currentItem = nextPosition
} catch (e: Exception) {
// Just in case cursor changes.
}
}
nextStory()
} else {
if (pager == null) return
val nextPosition = pager!!.currentItem - 1
if (nextPosition >= 0) {
try {
pager!!.currentItem = nextPosition
} catch (e: Exception) {
// Just in case cursor changes.
}
previousStory()
}
}
private fun nextStory() {
if (pager == null) return
val nextPosition = pager!!.currentItem + 1
if (nextPosition < readingAdapter!!.count) {
try {
pager!!.currentItem = nextPosition
} catch (e: Exception) {
// Just in case cursor changes.
}
}
}
private fun previousStory() {
if (pager == null) return
val nextPosition = pager!!.currentItem - 1
if (nextPosition >= 0) {
try {
pager!!.currentItem = nextPosition
} catch (e: Exception) {
// Just in case cursor changes.
}
}
}
@ -774,6 +795,10 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
// Required to prevent the default sound playing when the volume key is pressed
return if (isVolumeKeyNavigationEvent(keyCode)) {
true
} else if (KeyboardManager.hasHardwareKeyboard(this)) {
val handledKeyCode = keyboardManager.onKeyUp(keyCode, event)
if (handledKeyCode) true
else super.onKeyUp(keyCode, event)
} else {
super.onKeyUp(keyCode, event)
}
@ -797,6 +822,27 @@ abstract class Reading : NbActivity(), OnPageChangeListener, ScrollChangeListene
if (isActive) feedUtils.markStoryAsRead(story, this@Reading)
}
override fun onKeyboardEvent(event: KeyboardEvent) {
when (event) {
KeyboardEvent.NextStory -> nextStory()
KeyboardEvent.PreviousStory -> previousStory()
KeyboardEvent.NextUnreadStory -> nextUnread()
KeyboardEvent.OpenInBrowser -> readingFragment?.openBrowser()
KeyboardEvent.OpenStoryTrainer -> readingFragment?.openStoryTrainer()
KeyboardEvent.SaveUnsaveStory -> readingFragment?.switchStorySavedState(true)
KeyboardEvent.ScrollToComments -> readingFragment?.scrollToComments()
KeyboardEvent.ShareStory -> readingFragment?.openShareDialog()
KeyboardEvent.ToggleReadUnread -> readingFragment?.switchMarkStoryReadState(true)
KeyboardEvent.ToggleTextView -> readingFragment?.switchSelectedViewMode()
KeyboardEvent.Tutorial -> readingFragment?.showStoryShortcuts()
KeyboardEvent.PageDown ->
readingFragment?.scrollVerticallyBy(UIUtils.dp2px(this, VERTICAL_SCROLL_DISTANCE_DP))
KeyboardEvent.PageUp ->
readingFragment?.scrollVerticallyBy(UIUtils.dp2px(this, -VERTICAL_SCROLL_DISTANCE_DP))
else -> {}
}
}
companion object {
const val EXTRA_FEEDSET = "feed_set"
const val EXTRA_STORY_HASH = "story_hash"

View file

@ -62,15 +62,14 @@ public class WidgetConfig extends FeedChooser {
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_select_all:
selectAllFeeds();
return true;
case R.id.menu_select_none:
replaceWidgetFeedIds(Collections.emptySet());
return true;
default:
return super.onOptionsItemSelected(item);
if (item.getItemId() == R.id.menu_select_all) {
selectAllFeeds();
return true;
} else if (item.getItemId() == R.id.menu_select_none) {
replaceWidgetFeedIds(Collections.emptySet());
return true;
} else {
return super.onOptionsItemSelected(item);
}
}

View file

@ -24,13 +24,12 @@ import com.newsblur.domain.UserProfile;
import com.newsblur.network.domain.CommentResponse;
import com.newsblur.network.domain.StoriesResponse;
import com.newsblur.util.AppConstants;
import com.newsblur.util.CursorFilters;
import com.newsblur.util.FeedSet;
import com.newsblur.util.PrefsUtils;
import com.newsblur.util.ReadingAction;
import com.newsblur.util.ReadFilter;
import com.newsblur.util.StateFilter;
import com.newsblur.util.StoryOrder;
import com.newsblur.util.UIUtils;
import java.util.Arrays;
import java.util.ArrayList;
@ -56,14 +55,12 @@ public class BlurDatabaseHelper {
// manual synchro isn't needed if you only use one DBHelper, but at present the app uses several
public final static Object RW_MUTEX = new Object();
private Context context;
private final BlurDatabase dbWrapper;
private SQLiteDatabase dbRO;
private SQLiteDatabase dbRW;
private final SQLiteDatabase dbRO;
private final SQLiteDatabase dbRW;
public BlurDatabaseHelper(Context context) {
com.newsblur.util.Log.d(this.getClass().getName(), "new DB conn requested");
this.context = context;
synchronized (RW_MUTEX) {
dbWrapper = new BlurDatabase(context);
dbRO = dbWrapper.getRO();
@ -328,8 +325,7 @@ public class BlurDatabaseHelper {
return urls;
}
public void insertStories(StoriesResponse apiResponse, boolean forImmediateReading) {
StateFilter intelState = PrefsUtils.getStateFilter(context);
public void insertStories(StoriesResponse apiResponse, StateFilter stateFilter, boolean forImmediateReading) {
synchronized (RW_MUTEX) {
// do not attempt to use beginTransactionNonExclusive() to reduce lock time for this very heavy set
// of calls. most versions of Android incorrectly implement the underlying SQLite calls and will
@ -369,7 +365,7 @@ public class BlurDatabaseHelper {
}
insertSingleStoryExtSync(story);
// if the story is being fetched for the immediate session, also add the hash to the session table
if (forImmediateReading && story.isStoryVisibileInState(intelState)) {
if (forImmediateReading && story.isStoryVisibleInState(stateFilter)) {
ContentValues sessionHashValues = new ContentValues();
sessionHashValues.put(DatabaseConstants.READING_SESSION_STORY_HASH, story.storyHash);
dbRW.insert(DatabaseConstants.READING_SESSION_TABLE, null, sessionHashValues);
@ -481,7 +477,7 @@ public class BlurDatabaseHelper {
* to reflect a social action, but that the new copy is missing some fields. Attempt to merge the
* new story with the old one.
*/
public void updateStory(StoriesResponse apiResponse, boolean forImmediateReading) {
public void updateStory(StoriesResponse apiResponse, StateFilter stateFilter, boolean forImmediateReading) {
if (apiResponse.story == null) {
com.newsblur.util.Log.e(this, "updateStory called on response with missing single story");
return;
@ -500,7 +496,7 @@ public class BlurDatabaseHelper {
apiResponse.story.starredTimestamp = oldStory.starredTimestamp;
apiResponse.story.read = oldStory.read;
}
insertStories(apiResponse, forImmediateReading);
insertStories(apiResponse, stateFilter, forImmediateReading);
}
/**
@ -754,8 +750,8 @@ public class BlurDatabaseHelper {
ContentValues values = new ContentValues();
values.put(DatabaseConstants.STORY_READ, true);
String rangeSelection = null;
if (olderThan != null) rangeSelection = DatabaseConstants.STORY_TIMESTAMP + " <= " + olderThan.toString();
if (newerThan != null) rangeSelection = DatabaseConstants.STORY_TIMESTAMP + " >= " + newerThan.toString();
if (olderThan != null) rangeSelection = DatabaseConstants.STORY_TIMESTAMP + " <= " + olderThan;
if (newerThan != null) rangeSelection = DatabaseConstants.STORY_TIMESTAMP + " >= " + newerThan;
StringBuilder feedSelection = null;
if (fs.isAllNormal()) {
// a null selection is fine for all stories
@ -982,7 +978,7 @@ public class BlurDatabaseHelper {
}
}
public void setStoryShared(String hash, boolean shared) {
public void setStoryShared(String hash, @Nullable String currentUserId, boolean shared) {
// get a fresh copy of the story from the DB so we can append to the shared ID set
Cursor c = dbRO.query(DatabaseConstants.STORY_TABLE,
new String[]{DatabaseConstants.STORY_SHARED_USER_IDS},
@ -998,15 +994,13 @@ public class BlurDatabaseHelper {
String[] sharedUserIds = TextUtils.split(c.getString(c.getColumnIndex(DatabaseConstants.STORY_SHARED_USER_IDS)), ",");
closeQuietly(c);
// the id to append to or remove from the shared list (the current user)
String currentUser = PrefsUtils.getUserId(context);
// append to set and update DB
Set<String> newIds = new HashSet<String>(Arrays.asList(sharedUserIds));
// the id to append to or remove from the shared list (the current user)
if (shared) {
newIds.add(currentUser);
newIds.add(currentUserId);
} else {
newIds.remove(currentUser);
newIds.remove(currentUserId);
}
ContentValues values = new ContentValues();
values.put(DatabaseConstants.STORY_SHARED_USER_IDS, TextUtils.join(",", newIds));
@ -1146,16 +1140,16 @@ public class BlurDatabaseHelper {
return rawQuery(q.toString(), null, cancellationSignal);
}
public Cursor getActiveStoriesCursor(FeedSet fs, CancellationSignal cancellationSignal) {
final StoryOrder order = PrefsUtils.getStoryOrder(context, fs);
public Cursor getActiveStoriesCursor(FeedSet fs, CursorFilters cursorFilters, CancellationSignal cancellationSignal) {
// get the stories for this FS
Cursor result = getActiveStoriesCursorNoPrep(fs, order, cancellationSignal);
Cursor result = getActiveStoriesCursorNoPrep(fs, cursorFilters.getStoryOrder(), cancellationSignal);
// if the result is blank, try to prime the session table with existing stories, in case we
// are offline, but if a session is started, just use what was there so offsets don't change.
if (result.getCount() < 1) {
if (AppConstants.VERBOSE_LOG) Log.d(this.getClass().getName(), "priming reading session");
prepareReadingSession(fs);
result = getActiveStoriesCursorNoPrep(fs, order, cancellationSignal);
prepareReadingSession(fs, cursorFilters.getStateFilter(), cursorFilters.getReadFilter());
result = getActiveStoriesCursorNoPrep(fs, cursorFilters.getStoryOrder(), cancellationSignal);
}
return result;
}
@ -1183,18 +1177,12 @@ public class BlurDatabaseHelper {
synchronized (RW_MUTEX) {dbRW.delete(DatabaseConstants.READING_SESSION_TABLE, null, null);}
}
public void prepareReadingSession(FeedSet fs) {
ReadFilter readFilter = PrefsUtils.getReadFilter(context, fs);
StateFilter stateFilter = PrefsUtils.getStateFilter(context);
prepareReadingSession(fs, stateFilter, readFilter);
}
/**
* Populates the reading session table with hashes of already-fetched stories that meet the
* criteria for the given FeedSet and filters; these hashes will be supplemented by hashes
* fetched via the API and used to actually select story data when rendering story lists.
*/
private void prepareReadingSession(FeedSet fs, StateFilter stateFilter, ReadFilter readFilter) {
public void prepareReadingSession(FeedSet fs, StateFilter stateFilter, ReadFilter readFilter) {
// a selection filter that will be used to pull active story hashes from the stories table into the reading session table
StringBuilder sel = new StringBuilder();
// any selection args that need to be used within the inner select statement
@ -1366,8 +1354,7 @@ public class BlurDatabaseHelper {
* will show up in the UI with reduced functionality until the server gets back to us with
* an ID at which time the placeholder will be removed.
*/
public void insertCommentPlaceholder(String storyId, String feedId, String commentText) {
String userId = PrefsUtils.getUserId(context);
public void insertCommentPlaceholder(String storyId, @Nullable String userId, String commentText) {
Comment comment = new Comment();
comment.isPlaceholder = true;
comment.id = Comment.PLACEHOLDER_COMMENT_ID + storyId + userId;
@ -1399,19 +1386,18 @@ public class BlurDatabaseHelper {
synchronized (RW_MUTEX) {dbRW.delete(DatabaseConstants.REPLY_TABLE, DatabaseConstants.REPLY_ID + " = ?", new String[]{replyId});}
}
public void clearSelfComments(String storyId) {
String userId = PrefsUtils.getUserId(context);
synchronized (RW_MUTEX) {dbRW.delete(DatabaseConstants.COMMENT_TABLE,
public void clearSelfComments(String storyId, @Nullable String userId) {
synchronized (RW_MUTEX) {dbRW.delete(DatabaseConstants.COMMENT_TABLE,
DatabaseConstants.COMMENT_STORYID + " = ? AND " + DatabaseConstants.COMMENT_USERID + " = ?",
new String[]{storyId, userId});}
}
public void setCommentLiked(String storyId, String userId, String feedId, boolean liked) {
public void setCommentLiked(String storyId, String commentUserId, @Nullable String currentUserId, boolean liked) {
// get a fresh copy of the story from the DB so we can append to the shared ID set
Cursor c = dbRO.query(DatabaseConstants.COMMENT_TABLE,
null,
DatabaseConstants.COMMENT_STORYID + " = ? AND " + DatabaseConstants.COMMENT_USERID + " = ?",
new String[]{storyId, userId},
new String[]{storyId, commentUserId},
null, null, null);
if ((c == null)||(c.getCount() < 1)) {
Log.w(this.getClass().getName(), "comment removed before finishing mark-liked");
@ -1422,15 +1408,13 @@ public class BlurDatabaseHelper {
Comment comment = Comment.fromCursor(c);
closeQuietly(c);
// the new id to append/remove from the liking list (the current user)
String currentUser = PrefsUtils.getUserId(context);
// append to set and update DB
Set<String> newIds = new HashSet<String>(Arrays.asList(comment.likingUsers));
// the new id to append/remove from the liking list (the current user)
if (liked) {
newIds.add(currentUser);
newIds.add(currentUserId);
} else {
newIds.remove(currentUser);
newIds.remove(currentUserId);
}
ContentValues values = new ContentValues();
values.put(DatabaseConstants.COMMENT_LIKING_USERS, TextUtils.join(",", newIds));
@ -1458,7 +1442,7 @@ public class BlurDatabaseHelper {
return replies;
}
public void insertReplyPlaceholder(String storyId, String feedId, String commentUserId, String replyText) {
public void insertReplyPlaceholder(String storyId, @Nullable String userId, String commentUserId, String replyText) {
// get a fresh copy of the comment so we can discover the ID
Cursor c = dbRO.query(DatabaseConstants.COMMENT_TABLE,
null,
@ -1477,7 +1461,7 @@ public class BlurDatabaseHelper {
Reply reply = new Reply();
reply.commentId = comment.id;
reply.text = replyText;
reply.userId = PrefsUtils.getUserId(context);
reply.userId = userId;
reply.date = new Date();
reply.id = Reply.PLACEHOLDER_COMMENT_ID + storyId + comment.id + reply.userId;
synchronized (RW_MUTEX) {dbRW.insertWithOnConflict(DatabaseConstants.REPLY_TABLE, null, reply.getValues(), SQLiteDatabase.CONFLICT_REPLACE);}
@ -1582,11 +1566,8 @@ public class BlurDatabaseHelper {
public static void closeQuietly(Cursor c) {
if (c == null) return;
try {c.close();} catch (Exception e) {;}
}
public void sendSyncUpdate(int updateType) {
UIUtils.syncUpdateStatus(context, updateType);
try {c.close();} catch (Exception e) {
}
}
private static String conjoinSelections(CharSequence... args) {

View file

@ -413,52 +413,42 @@ public class StoryViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_mark_story_as_read:
if (item.getItemId() == R.id.menu_mark_story_as_read) {
feedUtils.markStoryAsRead(story, context);
return true;
case R.id.menu_mark_story_as_unread:
} else if (item.getItemId() == R.id.menu_mark_story_as_unread) {
feedUtils.markStoryUnread(story, context);
return true;
case R.id.menu_mark_older_stories_as_read:
} else if (item.getItemId() == R.id.menu_mark_older_stories_as_read) {
feedUtils.markRead(context, fs, story.timestamp, null, R.array.mark_older_read_options);
return true;
case R.id.menu_mark_newer_stories_as_read:
} else if (item.getItemId() == R.id.menu_mark_newer_stories_as_read) {
feedUtils.markRead(context, fs, null, story.timestamp, R.array.mark_newer_read_options);
return true;
case R.id.menu_send_story:
} else if (item.getItemId() == R.id.menu_send_story) {
feedUtils.sendStoryUrl(story, context);
return true;
case R.id.menu_send_story_full:
} else if (item.getItemId() == R.id.menu_send_story_full) {
feedUtils.sendStoryFull(story, context);
return true;
case R.id.menu_save_story:
} else if (item.getItemId() == R.id.menu_save_story) {
//TODO get folder name
feedUtils.setStorySaved(story, true, context, null);
return true;
case R.id.menu_unsave_story:
} else if (item.getItemId() == R.id.menu_unsave_story) {
feedUtils.setStorySaved(story, false, context, null);
return true;
case R.id.menu_intel:
} else if (item.getItemId() == R.id.menu_intel) {
if (story.feedId.equals("0")) return true; // cannot train on feedless stories
StoryIntelTrainerFragment intelFrag = StoryIntelTrainerFragment.newInstance(story, fs);
intelFrag.show(context.getSupportFragmentManager(), StoryIntelTrainerFragment.class.getName());
return true;
case R.id.menu_go_to_feed:
} else if (item.getItemId() == R.id.menu_go_to_feed) {
FeedSet fs = FeedSet.singleFeed(story.feedId);
FeedItemsList.startActivity(context, fs,
feedUtils.getFeed(story.feedId), null, null);
return true;
default:
} else {
return false;
}
}

View file

@ -303,7 +303,7 @@ open class ItemListContextMenuDelegateImpl(
private fun restartReadingSession(fragment: ItemSetFragment, fs: FeedSet) {
NBSyncService.resetFetchState(fs)
feedUtils.prepareReadingSession(fs, true)
feedUtils.prepareReadingSession(activity, fs, true)
triggerSync(activity)
fragment.resetEmptyState()
fragment.hasUpdated()

View file

@ -10,10 +10,8 @@ import androidx.fragment.app.DialogFragment
import com.newsblur.R
import com.newsblur.activity.*
import com.newsblur.database.BlurDatabaseHelper
import com.newsblur.fragment.FolderListFragment
import com.newsblur.fragment.LoginAsDialogFragment
import com.newsblur.fragment.LogoutDialogFragment
import com.newsblur.fragment.NewslettersFragment
import com.newsblur.fragment.*
import com.newsblur.keyboard.KeyboardManager
import com.newsblur.service.NBSyncService
import com.newsblur.util.ListTextSize
import com.newsblur.util.ListTextSize.Companion.fromSize
@ -44,6 +42,10 @@ class MainContextMenuDelegateImpl(
menu.findItem(R.id.menu_loginas).isVisible = true
}
if (KeyboardManager.hasHardwareKeyboard(activity)) {
menu.findItem(R.id.menu_shortcuts).isVisible = true
}
when (PrefsUtils.getSelectedTheme(activity)) {
ThemeValue.LIGHT -> menu.findItem(R.id.menu_theme_light).isChecked = true
ThemeValue.DARK -> menu.findItem(R.id.menu_theme_dark).isChecked = true
@ -184,10 +186,15 @@ class MainContextMenuDelegateImpl(
true
}
R.id.menu_newsletters -> {
val newFragment: DialogFragment = NewslettersFragment()
val newFragment = NewslettersFragment()
newFragment.show(activity.supportFragmentManager, NewslettersFragment::class.java.name)
true
}
R.id.menu_shortcuts -> {
val newFragment = FeedsShortcutFragment()
newFragment.show(activity.supportFragmentManager, FeedsShortcutFragment::class.java.name)
true
}
else -> false
}
}

View file

@ -238,7 +238,7 @@ public class Story implements Serializable {
}
}
public boolean isStoryVisibileInState(StateFilter state) {
public boolean isStoryVisibleInState(StateFilter state) {
int score = intelligence.calcTotalIntel();
switch (state) {
case ALL:
@ -252,7 +252,7 @@ public class Story implements Serializable {
case NEG:
return (score < 0);
case SAVED:
return (starred == true);
return (starred);
default:
return true;
}

View file

@ -24,10 +24,9 @@ import com.newsblur.fragment.AddFeedFragment.AddFeedAdapter.FolderViewHolder
import com.newsblur.network.APIManager
import com.newsblur.service.NBSyncService
import com.newsblur.util.AppConstants
import com.newsblur.util.UIUtils
import com.newsblur.util.executeAsyncTask
import dagger.hilt.android.AndroidEntryPoint
import java.util.*
import java.util.Collections
import javax.inject.Inject
@AndroidEntryPoint
@ -88,14 +87,14 @@ class AddFeedFragment : DialogFragment() {
binding.inputFolderName.text.clear()
addFeed(activity, apiManager, folderName)
} else {
UIUtils.safeToast(activity, R.string.add_folder_error, Toast.LENGTH_SHORT)
Toast.makeText(activity, R.string.add_folder_error, Toast.LENGTH_SHORT).show()
}
}
)
}
private fun addFeed(activity: Activity, apiManager: APIManager, folderName: String?) {
binding.textSyncStatus.visibility = View.VISIBLE
binding.containerSyncStatus.visibility = View.VISIBLE
lifecycleScope.executeAsyncTask(
doInBackground = {
(activity as AddFeedProgressListener).addFeedStarted()
@ -103,7 +102,7 @@ class AddFeedFragment : DialogFragment() {
apiManager.addFeed(feedUrl, folderName)
},
onPostExecute = {
binding.textSyncStatus.visibility = View.GONE
binding.containerSyncStatus.visibility = View.GONE
val intent = Intent(activity, Main::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
if (!it.isError) {
@ -111,7 +110,7 @@ class AddFeedFragment : DialogFragment() {
NBSyncService.forceFeedsFolders()
intent.putExtra(Main.EXTRA_FORCE_SHOW_FEED_ID, it.feed.feedId)
} else {
UIUtils.safeToast(activity, R.string.add_feed_error, Toast.LENGTH_SHORT)
Toast.makeText(activity, R.string.add_feed_error, Toast.LENGTH_SHORT).show()
}
activity.startActivity(intent)
activity.finish()
@ -119,8 +118,7 @@ class AddFeedFragment : DialogFragment() {
)
}
private class AddFeedAdapter
constructor(private val listener: OnFolderClickListener) : RecyclerView.Adapter<FolderViewHolder>() {
private class AddFeedAdapter(private val listener: OnFolderClickListener) : RecyclerView.Adapter<FolderViewHolder>() {
private val folders: MutableList<Folder> = ArrayList()
@ -145,7 +143,7 @@ class AddFeedFragment : DialogFragment() {
Collections.sort(folders, Folder.FolderComparator)
this.folders.clear()
this.folders.addAll(folders)
notifyDataSetChanged()
this.notifyDataSetChanged()
}
class FolderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

View file

@ -11,10 +11,10 @@ import com.newsblur.view.StateToggleButton;
import com.newsblur.view.StateToggleButton.StateChangedListener;
import com.newsblur.util.StateFilter;
public class FeedIntelligenceSelectorFragment extends Fragment implements StateChangedListener {
public class FeedSelectorFragment extends Fragment implements StateChangedListener {
private StateToggleButton button;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
final View v = inflater.inflate(R.layout.fragment_intelligenceselector, null);
@ -27,7 +27,7 @@ public class FeedIntelligenceSelectorFragment extends Fragment implements StateC
public void changedState(StateFilter state) {
((StateChangedListener) getActivity()).changedState(state);
}
public void setState(StateFilter state) {
button.setState(state);
}

View file

@ -0,0 +1,19 @@
package com.newsblur.fragment
import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.newsblur.databinding.FeedsShortcutsDialogBinding
class FeedsShortcutFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = FeedsShortcutsDialogBinding.inflate(layoutInflater)
return AlertDialog.Builder(requireContext()).apply {
setView(binding.root)
setPositiveButton(android.R.string.ok, null)
}.create()
}
}

View file

@ -8,7 +8,6 @@ import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -31,6 +30,7 @@ import com.newsblur.di.IconLoader;
import com.newsblur.di.ThumbnailLoader;
import com.newsblur.domain.Story;
import com.newsblur.service.NBSyncService;
import com.newsblur.util.CursorFilters;
import com.newsblur.util.FeedSet;
import com.newsblur.util.FeedUtils;
import com.newsblur.util.ImageLoader;
@ -149,15 +149,12 @@ public class ItemSetFragment extends NbFragment {
// disable the throbbers if animations are going to have a zero time scale
boolean isDisableAnimations = ViewUtils.isPowerSaveMode(requireContext());
int[] colorsArray = {ContextCompat.getColor(requireContext(), R.color.refresh_1),
ContextCompat.getColor(requireContext(), R.color.refresh_2),
ContextCompat.getColor(requireContext(), R.color.refresh_3),
ContextCompat.getColor(requireContext(), R.color.refresh_4)};
int[] colorsArray = UIUtils.getLoadingColorsArray(requireContext());
binding.topLoadingThrob.setEnabled(!isDisableAnimations);
binding.topLoadingThrob.setColors(colorsArray);
View footerView = inflater.inflate(R.layout.row_loading_throbber, null);
bottomProgressView = (ProgressThrobber) footerView.findViewById(R.id.itemlist_loading_throb);
bottomProgressView = footerView.findViewById(R.id.itemlist_loading_throb);
bottomProgressView.setEnabled(!isDisableAnimations);
bottomProgressView.setColors(colorsArray);
@ -271,7 +268,7 @@ public class ItemSetFragment extends NbFragment {
public void hasUpdated() {
FeedSet fs = getFeedSet();
if (isAdded() && fs != null) {
storiesViewModel.getActiveStories(fs);
storiesViewModel.getActiveStories(fs, new CursorFilters(requireContext(), fs));
}
}
@ -391,7 +388,7 @@ public class ItemSetFragment extends NbFragment {
// ensure we have measured
if (itemGridWidthPx > 0) {
int itemGridWidthDp = Math.round(UIUtils.px2dp(getActivity(), itemGridWidthPx));
int itemGridWidthDp = Math.round(UIUtils.px2dp(requireContext(), itemGridWidthPx));
colsCoarse = itemGridWidthDp / 300;
colsMed = itemGridWidthDp / 200;
colsFine = itemGridWidthDp / 150;
@ -416,7 +413,7 @@ public class ItemSetFragment extends NbFragment {
if (listStyle == StoryListStyle.LIST) {
gridSpacingPx = 0;
} else {
gridSpacingPx = UIUtils.dp2px(getActivity(), GRID_SPACING_DP);
gridSpacingPx = UIUtils.dp2px(requireContext(), GRID_SPACING_DP);
}
}
@ -435,8 +432,8 @@ public class ItemSetFragment extends NbFragment {
}
RecyclerView.ItemAnimator anim = binding.itemgridfragmentGrid.getItemAnimator();
anim.setAddDuration((long) ((anim.getAddDuration() + targetAddDuration)/2L));
anim.setMoveDuration((long) ((anim.getMoveDuration() + targetMovDuration)/2L));
anim.setAddDuration((anim.getAddDuration() + targetAddDuration)/2L);
anim.setMoveDuration((anim.getMoveDuration() + targetMovDuration)/2L);
}
private void onScrolled(RecyclerView recyclerView, int dx, int dy) {

View file

@ -15,8 +15,7 @@ import com.newsblur.util.setViewVisible
class NewslettersFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val view = layoutInflater.inflate(R.layout.newsletter_dialog, null)
val binding: NewsletterDialogBinding = NewsletterDialogBinding.bind(view)
val binding = NewsletterDialogBinding.inflate(layoutInflater)
val emailAddress = generateEmail()
binding.txtEmail.text = emailAddress

View file

@ -47,10 +47,8 @@ abstract class ProfileActivityDetailsFragment : Fragment(), OnItemClickListener
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_profileactivity, null)
binding = FragmentProfileactivityBinding.bind(view)
val colorsArray = intArrayOf(ContextCompat.getColor(requireContext(), R.color.refresh_1),
ContextCompat.getColor(requireContext(), R.color.refresh_2),
ContextCompat.getColor(requireContext(), R.color.refresh_3),
ContextCompat.getColor(requireContext(), R.color.refresh_4))
val colorsArray = UIUtils.getLoadingColorsArray(requireContext())
binding.emptyViewLoadingThrob.setColors(*colorsArray)
binding.profileDetailsActivitylist.setFooterDividersEnabled(false)
binding.profileDetailsActivitylist.emptyView = binding.emptyView

View file

@ -15,6 +15,7 @@ import android.webkit.WebView.HitTestResult
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
@ -30,6 +31,7 @@ import com.newsblur.di.StoryImageCache
import com.newsblur.domain.Classifier
import com.newsblur.domain.Story
import com.newsblur.domain.UserDetails
import com.newsblur.keyboard.KeyboardManager
import com.newsblur.network.APIManager
import com.newsblur.service.NBSyncReceiver.Companion.UPDATE_INTEL
import com.newsblur.service.NBSyncReceiver.Companion.UPDATE_SOCIAL
@ -198,10 +200,10 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.storyContextMenuButton.setOnClickListener { onClickMenuButton() }
readingItemActionsBinding.markReadStoryButton.setOnClickListener { clickMarkStoryRead() }
readingItemActionsBinding.trainStoryButton.setOnClickListener { clickTrain() }
readingItemActionsBinding.saveStoryButton.setOnClickListener { clickSave() }
readingItemActionsBinding.shareStoryButton.setOnClickListener { clickShare() }
readingItemActionsBinding.markReadStoryButton.setOnClickListener { switchMarkStoryReadState() }
readingItemActionsBinding.trainStoryButton.setOnClickListener { openStoryTrainer() }
readingItemActionsBinding.saveStoryButton.setOnClickListener { switchStorySavedState() }
readingItemActionsBinding.shareStoryButton.setOnClickListener { openShareDialog() }
}
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) {
@ -262,6 +264,10 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
menu.findItem(R.id.menu_reading_save).setTitle(if (story!!.starred) R.string.menu_unsave_story else R.string.menu_save_story)
if (fs!!.isFilterSaved || fs!!.isAllSaved || fs!!.singleSavedTag != null) menu.findItem(R.id.menu_reading_markunread).isVisible = false
if (KeyboardManager.hasHardwareKeyboard(requireContext())) {
menu.findItem(R.id.menu_shortcuts).isVisible = true
}
when (PrefsUtils.getSelectedTheme(requireContext())) {
ThemeValue.LIGHT -> menu.findItem(R.id.menu_theme_light).isChecked = true
ThemeValue.DARK -> menu.findItem(R.id.menu_theme_dark).isChecked = true
@ -298,8 +304,7 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
R.id.menu_reading_original -> {
val uri = Uri.parse(story!!.permalink)
UIUtils.handleUri(requireContext(), uri)
openBrowser()
true
}
R.id.menu_reading_sharenewsblur -> {
@ -317,6 +322,10 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
feedUtils.sendStoryFull(story, requireContext())
true
}
R.id.menu_shortcuts -> {
showStoryShortcuts()
true
}
R.id.menu_text_size_xs -> {
setTextSizeStyle(ReadingTextSize.XS)
true
@ -408,7 +417,7 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
R.id.menu_intel -> {
// check against training on feedless stories
if (story!!.feedId != "0") {
clickTrain()
openStoryTrainer()
}
true
}
@ -421,9 +430,19 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
}
}
private fun clickMarkStoryRead() {
if (story!!.read) feedUtils.markStoryUnread(story!!, requireContext())
else feedUtils.markStoryAsRead(story!!, requireContext())
fun switchMarkStoryReadState(notifyUser: Boolean = false) {
story?.let {
val msg = if (it.read) {
feedUtils.markStoryUnread(it, requireContext())
getString(R.string.story_unread)
}
else {
feedUtils.markStoryAsRead(it, requireContext())
getString(R.string.story_read)
}
if (notifyUser) UIUtils.showSnackBar(binding.root, msg)
} ?: Log.e(this.javaClass.name, "Error switching null story read state.")
}
private fun updateMarkStoryReadState() {
@ -437,7 +456,7 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
sampledQueue?.add { updateStoryReadTitleState.invoke() } ?: updateStoryReadTitleState.invoke()
}
private fun clickTrain() {
fun openStoryTrainer() {
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
intelFrag.show(requireActivity().supportFragmentManager, StoryIntelTrainerFragment::class.java.name)
}
@ -446,19 +465,25 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
readingItemActionsBinding.trainStoryButton.visibility = if (story!!.feedId == "0") View.GONE else View.VISIBLE
}
private fun clickSave() {
if (story!!.starred) {
feedUtils.setStorySaved(story!!.storyHash, false, requireContext())
} else {
feedUtils.setStorySaved(story!!.storyHash, true, requireContext())
}
fun switchStorySavedState(notifyUser: Boolean = false) {
story?.let {
val msg = if (it.starred) {
feedUtils.setStorySaved(it.storyHash, false, requireContext())
getString(R.string.story_saved)
} else {
feedUtils.setStorySaved(it.storyHash, true, requireContext())
getString(R.string.story_unsaved)
}
if (notifyUser) UIUtils.showSnackBar(binding.root, msg)
} ?: Log.e(this.javaClass.name, "Error switching null story saved state.")
}
private fun updateSaveButton() {
readingItemActionsBinding.saveStoryButton.setText(if (story!!.starred) R.string.unsave_this else R.string.save_this)
}
private fun clickShare() {
fun openShareDialog() {
val newFragment: DialogFragment = ShareDialogFragment.newInstance(story, sourceUserId)
newFragment.show(parentFragmentManager, "dialog")
}
@ -530,17 +555,7 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
val intelFrag = StoryIntelTrainerFragment.newInstance(story, fs)
intelFrag.show(parentFragmentManager, StoryIntelTrainerFragment::class.java.name)
})
binding.readingItemTitle.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View) {
try {
UIUtils.handleUri(requireContext(), Uri.parse(story!!.permalink))
} catch (t: Throwable) {
// we don't actually know if the user will successfully be able to open whatever string
// was in the permalink or if the Intent could throw errors
Log.e(this.javaClass.name, "Error opening story by permalink URL.", t)
}
}
})
binding.readingItemTitle.setOnClickListener { openBrowser() }
setupTagsAndIntel()
}
@ -963,6 +978,11 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
}
}
fun showStoryShortcuts() {
val newFragment = StoryShortcutsFragment()
newFragment.show(requireActivity().supportFragmentManager, StoryShortcutsFragment::class.java.name)
}
fun flagWebviewError() {
// TODO: enable a selective reload mechanism on load failures?
}
@ -989,8 +1009,31 @@ class ReadingItemFragment : NbFragment(), PopupMenu.OnMenuItemClickListener {
reloadStoryContent()
}
fun openBrowser() {
story?.let {
val uri = Uri.parse(it.permalink)
UIUtils.handleUri(requireContext(), uri)
} ?: Log.e(this.javaClass.name, "Error opening null story by permalink URL.")
}
fun scrollToComments() {
val targetView = if (readingItemActionsBinding.readingFriendCommentHeader.isVisible) {
readingItemActionsBinding.readingFriendCommentContainer
} else if (readingItemActionsBinding.readingPublicCommentHeader.isVisible) {
readingItemActionsBinding.readingPublicCommentContainer
} else null
targetView?.let {
it.parent.requestChildFocus(targetView, it)
}
}
fun scrollVerticallyBy(dy: Int) {
binding.readingScrollview.smoothScrollBy(0, dy)
}
companion object {
private const val BUNDLE_SCROLL_POS_REL = "scrollStateRel"
const val VERTICAL_SCROLL_DISTANCE_DP = 240
@JvmStatic
fun newInstance(story: Story?, feedTitle: String?, feedFaviconColor: String?, feedFaviconFade: String?, feedFaviconBorder: String?, faviconText: String?, faviconUrl: String?, classifier: Classifier?, displayFeedDetails: Boolean, sourceUserId: String?): ReadingItemFragment {

View file

@ -2,17 +2,16 @@ package com.newsblur.fragment;
import java.util.Map;
import android.app.Activity;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import android.text.InputType;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.TextView;
@ -57,6 +56,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -64,9 +64,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
fs = (FeedSet) getArguments().getSerializable("feedset");
classifier = dbHelper.getClassifierForFeed(story.feedId);
final Activity activity = getActivity();
LayoutInflater inflater = LayoutInflater.from(activity);
View v = inflater.inflate(R.layout.dialog_trainstory, null);
View v = getLayoutInflater().inflate(R.layout.dialog_trainstory, null);
binding = DialogTrainstoryBinding.bind(v);
// set up the special title training box for the title from this story and the associated buttons
@ -111,7 +109,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
// scan trained title fragments for this feed and see if any apply to this story
for (Map.Entry<String, Integer> rule : classifier.title.entrySet()) {
if (story.title.indexOf(rule.getKey()) >= 0) {
View row = inflater.inflate(R.layout.include_intel_row, null);
View row = getLayoutInflater().inflate(R.layout.include_intel_row, null);
TextView label = (TextView) row.findViewById(R.id.intel_row_label);
label.setText(rule.getKey());
UIUtils.setupIntelDialogRow(row, classifier.title, rule.getKey());
@ -121,7 +119,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
// list all tags for this story, trained or not
for (String tag : story.tags) {
View row = inflater.inflate(R.layout.include_intel_row, null);
View row = getLayoutInflater().inflate(R.layout.include_intel_row, null);
TextView label = (TextView) row.findViewById(R.id.intel_row_label);
label.setText(tag);
UIUtils.setupIntelDialogRow(row, classifier.tags, tag);
@ -131,7 +129,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
// there is a single author per story
if (!TextUtils.isEmpty(story.authors)) {
View rowAuthor = inflater.inflate(R.layout.include_intel_row, null);
View rowAuthor = getLayoutInflater().inflate(R.layout.include_intel_row, null);
TextView labelAuthor = (TextView) rowAuthor.findViewById(R.id.intel_row_label);
labelAuthor.setText(story.authors);
UIUtils.setupIntelDialogRow(rowAuthor, classifier.authors, story.authors);
@ -142,13 +140,13 @@ public class StoryIntelTrainerFragment extends DialogFragment {
// there is a single feed to be trained, but it is a bit odd in that the label is the title and
// the intel identifier is the feed ID
View rowFeed = inflater.inflate(R.layout.include_intel_row, null);
View rowFeed = getLayoutInflater().inflate(R.layout.include_intel_row, null);
TextView labelFeed = (TextView) rowFeed.findViewById(R.id.intel_row_label);
labelFeed.setText(feedUtils.getFeedTitle(story.feedId));
UIUtils.setupIntelDialogRow(rowFeed, classifier.feeds, story.feedId);
binding.existingFeedIntelContainer.addView(rowFeed);
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity());
builder.setTitle(R.string.story_intel_dialog_title);
builder.setView(v);
@ -164,7 +162,7 @@ public class StoryIntelTrainerFragment extends DialogFragment {
if ((newTitleTraining != null) && (!TextUtils.isEmpty(binding.intelTitleSelection.getSelection()))) {
classifier.title.put(binding.intelTitleSelection.getSelection(), newTitleTraining);
}
feedUtils.updateClassifier(story.feedId, classifier, fs, activity);
feedUtils.updateClassifier(story.feedId, classifier, fs, requireActivity());
StoryIntelTrainerFragment.this.dismiss();
}
});

View file

@ -0,0 +1,44 @@
package com.newsblur.fragment
import android.app.Dialog
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.AbsoluteSizeSpan
import android.text.style.StyleSpan
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.newsblur.R
import com.newsblur.databinding.StoryShortcutsDialogBinding
class StoryShortcutsFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = StoryShortcutsDialogBinding.inflate(layoutInflater)
SpannableString(getString(R.string.short_share_this_story_key)).apply {
shiftKeySpannable()
}.also {
binding.txtShareStoryKey.text = it
}
SpannableString(getString(R.string.short_page_up_key)).apply {
shiftKeySpannable()
}.also {
binding.txtPageUpKey.text = it
}
return AlertDialog.Builder(requireContext()).apply {
setView(binding.root)
setPositiveButton(android.R.string.ok, null)
}.create()
}
private fun SpannableString.shiftKeySpannable() {
setSpan(AbsoluteSizeSpan(18, true),
0, 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
setSpan(StyleSpan(Typeface.BOLD),
0, 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
}
}

View file

@ -0,0 +1,35 @@
package com.newsblur.keyboard
interface KeyboardListener {
fun onKeyboardEvent(event: KeyboardEvent)
}
sealed class KeyboardEvent {
/**
* Keyboard events for Home
*/
object OpenAllStories : KeyboardEvent()
object AddFeed : KeyboardEvent()
object SwitchViewRight : KeyboardEvent()
object SwitchViewLeft : KeyboardEvent()
/**
* Keyboard events for Reading
*/
object NextStory : KeyboardEvent()
object PreviousStory : KeyboardEvent()
object ToggleTextView : KeyboardEvent()
object NextUnreadStory : KeyboardEvent()
object ToggleReadUnread : KeyboardEvent()
object SaveUnsaveStory : KeyboardEvent()
object OpenInBrowser : KeyboardEvent()
object ShareStory : KeyboardEvent()
object ScrollToComments : KeyboardEvent()
object OpenStoryTrainer : KeyboardEvent()
object PageDown: KeyboardEvent()
object PageUp: KeyboardEvent()
object Tutorial: KeyboardEvent()
}

View file

@ -0,0 +1,147 @@
package com.newsblur.keyboard
import android.content.Context
import android.content.res.Configuration
import android.view.KeyEvent
class KeyboardManager {
private var listener: KeyboardListener? = null
fun addListener(listener: KeyboardListener) {
this.listener = listener
}
fun removeListener() {
this.listener = null
}
/**
* @return Return <code>true</code> to prevent this event from being propagated
* further, or <code>false</code> to indicate that you have not handled
*/
fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = when (keyCode) {
/**
* Home events
*/
KeyEvent.KEYCODE_E -> {
handleKeycodeE(event)
}
KeyEvent.KEYCODE_A -> {
handleKeycodeA(event)
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener?.onKeyboardEvent(KeyboardEvent.SwitchViewRight)
true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener?.onKeyboardEvent(KeyboardEvent.SwitchViewLeft)
true
}
/**
* Story events
*/
KeyEvent.KEYCODE_J,
KeyEvent.KEYCODE_DPAD_DOWN -> {
listener?.onKeyboardEvent(KeyboardEvent.PreviousStory)
true
}
KeyEvent.KEYCODE_K,
KeyEvent.KEYCODE_DPAD_UP -> {
listener?.onKeyboardEvent(KeyboardEvent.NextStory)
true
}
KeyEvent.KEYCODE_N -> {
listener?.onKeyboardEvent(KeyboardEvent.NextUnreadStory)
true
}
KeyEvent.KEYCODE_U, KeyEvent.KEYCODE_M -> {
listener?.onKeyboardEvent(KeyboardEvent.ToggleReadUnread)
true
}
KeyEvent.KEYCODE_S -> {
if (event.isShiftPressed) listener?.onKeyboardEvent(KeyboardEvent.ShareStory)
else listener?.onKeyboardEvent(KeyboardEvent.SaveUnsaveStory)
true
}
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_V -> {
listener?.onKeyboardEvent(KeyboardEvent.OpenInBrowser)
true
}
KeyEvent.KEYCODE_C -> {
listener?.onKeyboardEvent(KeyboardEvent.ScrollToComments)
true
}
KeyEvent.KEYCODE_T -> {
listener?.onKeyboardEvent(KeyboardEvent.OpenStoryTrainer)
true
}
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (event.isShiftPressed) {
listener?.onKeyboardEvent(KeyboardEvent.ToggleTextView)
true
} else false
}
KeyEvent.KEYCODE_SPACE -> {
if (event.isShiftPressed) listener?.onKeyboardEvent(KeyboardEvent.PageUp)
else listener?.onKeyboardEvent(KeyboardEvent.PageDown)
true
}
KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT -> {
listener?.onKeyboardEvent(KeyboardEvent.Tutorial)
true
}
else -> false
}
private fun handleKeycodeE(event: KeyEvent): Boolean = if (event.isAltPressed) {
listener?.onKeyboardEvent(KeyboardEvent.OpenAllStories)
true
} else false
private fun handleKeycodeA(event: KeyEvent): Boolean = if (event.isAltPressed) {
listener?.onKeyboardEvent(KeyboardEvent.AddFeed)
true
} else false
fun isKnownKeyCode(keyCode: Int): Boolean =
isShortcutKeyCode(keyCode) && isSpecialKeyCode(keyCode)
private fun isSpecialKeyCode(keyCode: Int) = when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
KeyEvent.KEYCODE_SPACE,
-> true
else -> false
}
private fun isShortcutKeyCode(keyCode: Int) = when (keyCode) {
KeyEvent.KEYCODE_E,
KeyEvent.KEYCODE_A,
KeyEvent.KEYCODE_J,
KeyEvent.KEYCODE_K,
KeyEvent.KEYCODE_N,
KeyEvent.KEYCODE_U,
KeyEvent.KEYCODE_M,
KeyEvent.KEYCODE_S,
KeyEvent.KEYCODE_O,
KeyEvent.KEYCODE_V,
KeyEvent.KEYCODE_C,
KeyEvent.KEYCODE_T,
-> true
else -> false
}
companion object {
@JvmStatic
fun hasHardwareKeyboard(context: Context) =
context.resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
}
}

View file

@ -205,7 +205,7 @@ public class APIManager {
public ProfileResponse updateUserProfile() {
final APIResponse response = get(buildUrl(APIConstants.PATH_MY_PROFILE));
if (!response.isError()) {
ProfileResponse profileResponse = (ProfileResponse) response.getResponse(gson, ProfileResponse.class);
ProfileResponse profileResponse = response.getResponse(gson, ProfileResponse.class);
PrefsUtils.saveUserDetails(context, profileResponse.user);
return profileResponse;
} else {
@ -234,14 +234,14 @@ public class APIManager {
values.put(APIConstants.PARAMETER_FEEDID, id);
}
APIResponse response = get(buildUrl(APIConstants.PATH_FEED_UNREAD_COUNT), values);
return (UnreadCountResponse) response.getResponse(gson, UnreadCountResponse.class);
return response.getResponse(gson, UnreadCountResponse.class);
}
public UnreadStoryHashesResponse getUnreadStoryHashes() {
ValueMultimap values = new ValueMultimap();
values.put(APIConstants.PARAMETER_INCLUDE_TIMESTAMPS, "1");
APIResponse response = get(buildUrl(APIConstants.PATH_UNREAD_HASHES), values);
return (UnreadStoryHashesResponse) response.getResponse(gson, UnreadStoryHashesResponse.class);
return response.getResponse(gson, UnreadStoryHashesResponse.class);
}
public StarredStoryHashesResponse getStarredStoryHashes() {
@ -256,7 +256,7 @@ public class APIManager {
}
values.put(APIConstants.PARAMETER_INCLUDE_HIDDEN, APIConstants.VALUE_TRUE);
APIResponse response = get(buildUrl(APIConstants.PATH_RIVER_STORIES), values);
return (StoriesResponse) response.getResponse(gson, StoriesResponse.class);
return response.getResponse(gson, StoriesResponse.class);
}
/**
@ -264,7 +264,7 @@ public class APIManager {
* request parameters as needed.
*/
public StoriesResponse getStories(FeedSet fs, int pageNumber, StoryOrder order, ReadFilter filter) {
Uri uri = null;
Uri uri;
ValueMultimap values = new ValueMultimap();
// create the URI and populate request params depending on what kind of stories we want
@ -331,29 +331,21 @@ public class APIManager {
}
APIResponse response = get(uri.toString(), values);
return (StoriesResponse) response.getResponse(gson, StoriesResponse.class);
return response.getResponse(gson, StoriesResponse.class);
}
public boolean followUser(final String userId) {
final ContentValues values = new ContentValues();
values.put(APIConstants.PARAMETER_USERID, userId);
final APIResponse response = post(buildUrl(APIConstants.PATH_FOLLOW), values);
if (!response.isError()) {
return true;
} else {
return false;
}
return !response.isError();
}
public boolean unfollowUser(final String userId) {
final ContentValues values = new ContentValues();
values.put(APIConstants.PARAMETER_USERID, userId);
final APIResponse response = post(buildUrl(APIConstants.PATH_UNFOLLOW), values);
if (!response.isError()) {
return true;
} else {
return false;
}
return !response.isError();
}
public APIResponse saveExternalStory(@NonNull String storyTitle, @NonNull String storyUrl) {
@ -386,7 +378,7 @@ public class APIManager {
APIResponse response = post(buildUrl(APIConstants.PATH_SHARE_STORY), values);
// this call returns a new copy of the story with all fields updated and some metadata
return (StoriesResponse) response.getResponse(gson, StoriesResponse.class);
return response.getResponse(gson, StoriesResponse.class);
}
public StoriesResponse unshareStory(String storyId, String feedId) {
@ -396,7 +388,7 @@ public class APIManager {
APIResponse response = post(buildUrl(APIConstants.PATH_UNSHARE_STORY), values);
// this call returns a new copy of the story with all fields updated and some metadata
return (StoriesResponse) response.getResponse(gson, StoriesResponse.class);
return response.getResponse(gson, StoriesResponse.class);
}
/**
@ -438,8 +430,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_USER_ID, userId);
final APIResponse response = get(buildUrl(APIConstants.PATH_USER_PROFILE), values);
if (!response.isError()) {
ProfileResponse profileResponse = (ProfileResponse) response.getResponse(gson, ProfileResponse.class);
return profileResponse;
return response.getResponse(gson, ProfileResponse.class);
} else {
return null;
}
@ -452,8 +443,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_PAGE_NUMBER, Integer.toString(pageNumber));
final APIResponse response = get(buildUrl(APIConstants.PATH_USER_ACTIVITIES), values);
if (!response.isError()) {
ActivitiesResponse activitiesResponse = (ActivitiesResponse) response.getResponse(gson, ActivitiesResponse.class);
return activitiesResponse;
return response.getResponse(gson, ActivitiesResponse.class);
} else {
return null;
}
@ -466,8 +456,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_PAGE_NUMBER, Integer.toString(pageNumber));
final APIResponse response = get(buildUrl(APIConstants.PATH_USER_INTERACTIONS), values);
if (!response.isError()) {
InteractionsResponse interactionsResponse = (InteractionsResponse) response.getResponse(gson, InteractionsResponse.class);
return interactionsResponse;
return response.getResponse(gson, InteractionsResponse.class);
} else {
return null;
}
@ -479,8 +468,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_STORYID, storyId);
final APIResponse response = get(buildUrl(APIConstants.PATH_STORY_TEXT), values);
if (!response.isError()) {
StoryTextResponse storyTextResponse = (StoryTextResponse) response.getResponse(gson, StoryTextResponse.class);
return storyTextResponse;
return response.getResponse(gson, StoryTextResponse.class);
} else {
return null;
}
@ -520,7 +508,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_REPLY_TEXT, reply);
APIResponse response = post(buildUrl(APIConstants.PATH_REPLY_TO), values);
// this call returns a new copy of the comment with all fields updated
return (CommentResponse) response.getResponse(gson, CommentResponse.class);
return response.getResponse(gson, CommentResponse.class);
}
public CommentResponse editReply(String storyId, String storyFeedId, String commentUserId, String replyId, String reply) {
@ -532,7 +520,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_REPLY_TEXT, reply);
APIResponse response = post(buildUrl(APIConstants.PATH_EDIT_REPLY), values);
// this call returns a new copy of the comment with all fields updated
return (CommentResponse) response.getResponse(gson, CommentResponse.class);
return response.getResponse(gson, CommentResponse.class);
}
public CommentResponse deleteReply(String storyId, String storyFeedId, String commentUserId, String replyId) {
@ -543,7 +531,7 @@ public class APIManager {
values.put(APIConstants.PARAMETER_REPLY_ID, replyId);
APIResponse response = post(buildUrl(APIConstants.PATH_DELETE_REPLY), values);
// this call returns a new copy of the comment with all fields updated
return (CommentResponse) response.getResponse(gson, CommentResponse.class);
return response.getResponse(gson, CommentResponse.class);
}
public NewsBlurResponse addFolder(String folderName) {
@ -711,10 +699,10 @@ public class APIManager {
}
private String builderGetParametersString(ContentValues values) {
List<String> parameters = new ArrayList<String>();
List<String> parameters = new ArrayList<>();
for (Entry<String, Object> entry : values.valueSet()) {
StringBuilder builder = new StringBuilder();
builder.append((String) entry.getKey());
builder.append(entry.getKey());
builder.append("=");
builder.append(NetworkUtils.encodeURL((String) entry.getValue()));
parameters.add(builder.toString());
@ -749,7 +737,7 @@ public class APIManager {
formBody.writeTo(buffer);
body = buffer.readUtf8();
} catch (Exception e) {
; // this is debug code, do not raise
// this is debug code, do not raise
}
Log.d(this.getClass().getName(), "post body: " + body);
}

View file

@ -3,7 +3,6 @@ package com.newsblur.network;
import java.io.IOException;
import java.net.HttpURLConnection;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
@ -109,7 +108,6 @@ public class APIResponse {
* may be used for calls that return data, or the parent class may be used if no
* return data are expected.
*/
@SuppressWarnings("unchecked")
public <T extends NewsBlurResponse> T getResponse(Gson gson, Class<T> classOfT) {
if (this.isError) {
// if we encountered an error, make a generic response type and populate

View file

@ -1,5 +1,6 @@
package com.newsblur.service;
import com.newsblur.util.ExtensionsKt;
import com.newsblur.util.PrefConstants;
import com.newsblur.util.PrefsUtils;
@ -8,7 +9,7 @@ public class CleanupService extends SubService {
public static boolean activelyRunning = false;
public CleanupService(NBSyncService parent) {
super(parent);
super(parent, ExtensionsKt.NBScope);
}
@Override

View file

@ -3,6 +3,7 @@ package com.newsblur.service;
import android.util.Log;
import com.newsblur.util.AppConstants;
import com.newsblur.util.ExtensionsKt;
import com.newsblur.util.PrefsUtils;
import java.util.Collections;
@ -19,7 +20,7 @@ public class ImagePrefetchService extends SubService {
static Set<String> ThumbnailQueue = Collections.synchronizedSet(new HashSet<>());
public ImagePrefetchService(NBSyncService parent) {
super(parent);
super(parent, ExtensionsKt.NBScope);
}
@Override
@ -33,7 +34,6 @@ public class ImagePrefetchService extends SubService {
if (! PrefsUtils.isImagePrefetchEnabled(parent)) return;
if (! PrefsUtils.isBackgroundNetworkAllowed(parent)) return;
startExpensiveCycle();
com.newsblur.util.Log.d(this, "story images to prefetch: " + StoryImageQueue.size());
// on each batch, re-query the DB for images associated with yet-unread stories
// this is a bit expensive, but we are running totally async at a really low priority
@ -66,7 +66,6 @@ public class ImagePrefetchService extends SubService {
if (! PrefsUtils.isImagePrefetchEnabled(parent)) return;
if (! PrefsUtils.isBackgroundNetworkAllowed(parent)) return;
startExpensiveCycle();
com.newsblur.util.Log.d(this, "story thumbs to prefetch: " + StoryImageQueue.size());
// on each batch, re-query the DB for images associated with yet-unread stories
// this is a bit expensive, but we are running totally async at a really low priority

View file

@ -38,6 +38,7 @@ import com.newsblur.network.domain.NewsBlurResponse;
import com.newsblur.network.domain.StoriesResponse;
import com.newsblur.network.domain.UnreadCountResponse;
import com.newsblur.util.AppConstants;
import com.newsblur.util.CursorFilters;
import com.newsblur.util.DefaultFeedView;
import com.newsblur.util.FeedSet;
import com.newsblur.util.FileCache;
@ -49,6 +50,7 @@ import com.newsblur.util.ReadingAction;
import com.newsblur.util.ReadFilter;
import com.newsblur.util.StateFilter;
import com.newsblur.util.StoryOrder;
import com.newsblur.util.UIUtils;
import com.newsblur.widget.WidgetUtils;
import java.util.ArrayList;
@ -411,6 +413,8 @@ public class NBSyncService extends JobService {
ActionsRunning = true;
StateFilter stateFilter = PrefsUtils.getStateFilter(this);
actionsloop : while (c.moveToNext()) {
sendSyncUpdate(UPDATE_STATUS);
String id = c.getString(c.getColumnIndexOrThrow(DatabaseConstants.ACTION_ID));
@ -427,7 +431,7 @@ public class NBSyncService extends JobService {
if ((ra.getTried() > 0) && (PendingFeed != null)) continue actionsloop;
com.newsblur.util.Log.d(this, "attempting action: " + ra.toContentValues().toString());
NewsBlurResponse response = ra.doRemote(apiManager, dbHelper);
NewsBlurResponse response = ra.doRemote(apiManager, dbHelper, stateFilter);
if (response == null) {
com.newsblur.util.Log.e(this.getClass().getName(), "Discarding reading action with client-side error.");
@ -471,7 +475,7 @@ public class NBSyncService extends JobService {
Log.d(this, "double-checking " + FollowupActions.size() + " actions");
int impactFlags = 0;
for (ReadingAction ra : FollowupActions) {
int impact = ra.doLocal(dbHelper, true);
int impact = ra.doLocal(this, dbHelper, true);
impactFlags |= impact;
}
sendSyncUpdate(impactFlags);
@ -766,7 +770,7 @@ public class NBSyncService extends JobService {
return;
}
prepareReadingSession(dbHelper, fs);
prepareReadingSession(this, dbHelper, fs);
LastFeedSet = fs;
@ -785,8 +789,7 @@ public class NBSyncService extends JobService {
int pageNumber = FeedPagesSeen.get(fs);
int totalStoriesSeen = FeedStoriesSeen.get(fs);
StoryOrder order = PrefsUtils.getStoryOrder(this, fs);
ReadFilter filter = PrefsUtils.getReadFilter(this, fs);
CursorFilters cursorFilters = new CursorFilters(this, fs);
StorySyncRunning = true;
sendSyncUpdate(UPDATE_STATUS);
@ -802,7 +805,7 @@ public class NBSyncService extends JobService {
}
pageNumber++;
StoriesResponse apiResponse = apiManager.getStories(fs, pageNumber, order, filter);
StoriesResponse apiResponse = apiManager.getStories(fs, pageNumber, cursorFilters.getStoryOrder(), cursorFilters.getReadFilter());
if (! isStoryResponseGood(apiResponse)) return;
@ -810,7 +813,7 @@ public class NBSyncService extends JobService {
return;
}
insertStories(apiResponse, fs);
insertStories(apiResponse, fs, cursorFilters.getStateFilter());
// re-do any very recent actions that were incorrectly overwritten by this page
finishActions();
sendSyncUpdate(UPDATE_STORY | UPDATE_STATUS);
@ -855,7 +858,7 @@ public class NBSyncService extends JobService {
private long workaroundReadStoryTimestamp;
private long workaroundGloblaSharedStoryTimestamp;
private void insertStories(StoriesResponse apiResponse, FeedSet fs) {
private void insertStories(StoriesResponse apiResponse, FeedSet fs, StateFilter stateFilter) {
if (fs.isAllRead()) {
// Ugly Hack Warning: the API doesn't vend the sortation key necessary to display
// stories when in the "read stories" view. It does, however, return them in the
@ -919,12 +922,12 @@ public class NBSyncService extends JobService {
}
com.newsblur.util.Log.d(NBSyncService.class.getName(), "got stories from main fetch loop: " + apiResponse.stories.length);
dbHelper.insertStories(apiResponse, true);
dbHelper.insertStories(apiResponse, stateFilter, true);
}
void insertStories(StoriesResponse apiResponse) {
void insertStories(StoriesResponse apiResponse, StateFilter stateFilter) {
com.newsblur.util.Log.d(NBSyncService.class.getName(), "got stories from sub sync: " + apiResponse.stories.length);
dbHelper.insertStories(apiResponse, false);
dbHelper.insertStories(apiResponse, stateFilter, false);
}
void prefetchOriginalText(StoriesResponse apiResponse) {
@ -1144,8 +1147,9 @@ public class NBSyncService extends JobService {
* set but also when we sync a page of stories, since there are no guarantees which
* will happen first.
*/
public static void prepareReadingSession(BlurDatabaseHelper dbHelper, FeedSet fs) {
public static void prepareReadingSession(Context context, BlurDatabaseHelper dbHelper, FeedSet fs) {
synchronized (PENDING_FEED_MUTEX) {
CursorFilters cursorFilters = new CursorFilters(context, fs);
if (! fs.equals(dbHelper.getSessionFeedSet())) {
com.newsblur.util.Log.d(NBSyncService.class.getName(), "preparing new reading session");
// the next fetch will be the start of a new reading session; clear it so it
@ -1153,10 +1157,10 @@ public class NBSyncService extends JobService {
dbHelper.clearStorySession();
// don't just rely on the auto-prepare code when fetching stories, it might be called
// after we insert our first page and not trigger
dbHelper.prepareReadingSession(fs);
dbHelper.prepareReadingSession(fs, cursorFilters.getStateFilter(), cursorFilters.getReadFilter());
// note which feedset we are loading so we can trigger another reset when it changes
dbHelper.setSessionFeedSet(fs);
dbHelper.sendSyncUpdate(UPDATE_STORY | UPDATE_STATUS);
UIUtils.syncUpdateStatus(context, UPDATE_STORY | UPDATE_STATUS);
}
}
}

View file

@ -5,6 +5,7 @@ import static com.newsblur.service.NBSyncReceiver.UPDATE_TEXT;
import com.newsblur.database.DatabaseConstants;
import com.newsblur.network.domain.StoryTextResponse;
import com.newsblur.util.AppConstants;
import com.newsblur.util.ExtensionsKt;
import com.newsblur.util.FeedUtils;
import java.util.HashSet;
@ -22,14 +23,12 @@ public class OriginalTextService extends SubService {
private static final Pattern imgSniff = Pattern.compile("<img[^>]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*>", Pattern.CASE_INSENSITIVE);
/** story hashes we need to fetch (from newly found stories) */
private static Set<String> Hashes;
static {Hashes = new HashSet<String>();}
private static final Set<String> Hashes = new HashSet<>();
/** story hashes we should fetch ASAP (they are waiting on-screen) */
private static Set<String> PriorityHashes;
static {PriorityHashes = new HashSet<String>();}
private static final Set<String> PriorityHashes = new HashSet<>();
public OriginalTextService(NBSyncService parent) {
super(parent);
super(parent, ExtensionsKt.NBScope);
}
@Override

View file

@ -1,129 +0,0 @@
package com.newsblur.service;
import static com.newsblur.service.NBSyncReceiver.UPDATE_STATUS;
import android.os.Process;
import com.newsblur.NbApplication;
import com.newsblur.util.AppConstants;
import com.newsblur.util.Log;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.RejectedExecutionException;
/**
* A utility construct to make NbSyncService a bit more modular by encapsulating sync tasks
* that can be run fully asynchronously from the main sync loop. Like all of the sync service,
* flags and data used by these modules need to be static so that parts of the app without a
* handle to the service object can access them.
*/
public abstract class SubService {
protected NBSyncService parent;
private ThreadPoolExecutor executor;
private long cycleStartTime = 0L;
private SubService() {
; // no default construction
}
SubService(NBSyncService parent) {
this.parent = parent;
executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
public void start() {
Runnable r = new Runnable() {
public void run() {
if (parent.stopSync()) return;
if (!NbApplication.isAppForeground()) {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND + Process.THREAD_PRIORITY_LESS_FAVORABLE );
} else {
Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE + Process.THREAD_PRIORITY_LESS_FAVORABLE );
}
Thread.currentThread().setName(this.getClass().getName());
exec_();
}
};
try {
executor.execute(r);
// enqueue a check task that will run strictly after the real one, so the callback
// can effectively check queue size to see if there are queued tasks
executor.execute(new Runnable() {
public void run() {
parent.checkCompletion();
parent.sendSyncUpdate(UPDATE_STATUS);
}
});
} catch (RejectedExecutionException ree) {
// this is perfectly normal, as service soft-stop mechanics might have shut down our thread pool
// while peer subservices are still running
}
}
private synchronized void exec_() {
try {
exec();
cycleStartTime = 0;
} catch (Exception e) {
com.newsblur.util.Log.e(this.getClass().getName(), "Sync error.", e);
}
}
protected abstract void exec();
public void shutdown() {
Log.d(this, "SubService stopping");
executor.shutdown();
try {
executor.awaitTermination(AppConstants.SHUTDOWN_SLACK_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
} finally {
Log.d(this, "SubService stopped");
}
}
public boolean isRunning() {
// don't advise completion until there are no tasks, or just one check task left
return (executor.getQueue().size() > 0);
}
/**
* If called at the beginning of an expensive loop in a SubService, enforces the maximum duty cycle
* defined in AppConstants by sleeping for a short while so the SubService does not dominate system
* resources.
*/
protected void startExpensiveCycle() {
if (cycleStartTime == 0) {
cycleStartTime = System.nanoTime();
return;
}
double lastCycleTime = (System.nanoTime() - cycleStartTime);
if (lastCycleTime < 1) return;
cycleStartTime = System.nanoTime();
double cooloffTime = lastCycleTime * (1.0 - AppConstants.MAX_BG_DUTY_CYCLE);
if (cooloffTime < 1) return;
long cooloffTimeMs = Math.round(cooloffTime / 1000000.0);
if (cooloffTimeMs > AppConstants.DUTY_CYCLE_BACKOFF_CAP_MILLIS) cooloffTimeMs = AppConstants.DUTY_CYCLE_BACKOFF_CAP_MILLIS;
if (NbApplication.isAppForeground()) {
com.newsblur.util.Log.d(this.getClass().getName(), "Sleeping for : " + cooloffTimeMs + "ms to enforce max duty cycle.");
try {
Thread.sleep(cooloffTimeMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}

View file

@ -0,0 +1,66 @@
package com.newsblur.service
import com.newsblur.util.Log
import com.newsblur.util.NBScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.CancellationException
/**
* A utility construct to make NbSyncService a bit more modular by encapsulating sync tasks
* that can be run fully asynchronously from the main sync loop. Like all of the sync service,
* flags and data used by these modules need to be static so that parts of the app without a
* handle to the service object can access them.
*/
abstract class SubService(
@JvmField
protected val parent: NBSyncService,
private val coroutineScope: CoroutineScope = NBScope,
) {
private var mainJob: Job? = null
protected abstract fun exec()
fun start() {
mainJob = coroutineScope.launch(Dispatchers.IO) {
if (parent.stopSync()) return@launch
Thread.currentThread().name = this@SubService.javaClass.name
execInternal()
if (isActive) {
parent.checkCompletion()
parent.sendSyncUpdate(NBSyncReceiver.UPDATE_STATUS)
}
}
}
private suspend fun execInternal() = coroutineScope {
try {
ensureActive()
exec()
} catch (e: Exception) {
Log.e(this@SubService.javaClass.name, "Sync error.", e)
}
}
fun shutdown() {
Log.d(this, "SubService shutdown")
try {
mainJob?.cancel()
} catch (e: CancellationException) {
Log.d(this, "SubService cancelled")
} finally {
Log.d(this, "SubService stopped")
}
}
val isRunning: Boolean
get() = mainJob?.isActive ?: false
}

View file

@ -24,8 +24,6 @@ import kotlinx.coroutines.launch
*/
class SubscriptionSyncService : JobService() {
private val scope = NBScope
override fun onStartJob(params: JobParameters?): Boolean {
Log.d(this, "onStartJob")
if (!PrefsUtils.hasCookie(this)) {
@ -33,10 +31,10 @@ class SubscriptionSyncService : JobService() {
return false
}
val subscriptionManager = SubscriptionManagerImpl(this@SubscriptionSyncService, scope)
val subscriptionManager = SubscriptionManagerImpl(this@SubscriptionSyncService)
subscriptionManager.startBillingConnection(object : SubscriptionsListener {
override fun onBillingConnectionReady() {
scope.launch {
NBScope.launch {
subscriptionManager.syncActiveSubscription()
Log.d(this, "sync active subscription completed.")
// manually call jobFinished after work is done

View file

@ -3,8 +3,10 @@ package com.newsblur.service;
import com.newsblur.network.domain.StoriesResponse;
import com.newsblur.network.domain.UnreadStoryHashesResponse;
import com.newsblur.util.AppConstants;
import com.newsblur.util.ExtensionsKt;
import com.newsblur.util.FeedUtils;
import com.newsblur.util.PrefsUtils;
import com.newsblur.util.StateFilter;
import com.newsblur.util.StoryOrder;
import java.util.ArrayList;
@ -25,7 +27,7 @@ public class UnreadsService extends SubService {
static { StoryHashQueue = new ArrayList<String>(); }
public UnreadsService(NBSyncService parent) {
super(parent);
super(parent, ExtensionsKt.NBScope);
}
@Override
@ -137,8 +139,6 @@ public class UnreadsService extends SubService {
boolean isTextPrefetchEnabled = PrefsUtils.isTextPrefetchEnabled(parent);
if (! (isOfflineEnabled || isEnableNotifications)) return;
startExpensiveCycle();
List<String> hashBatch = new ArrayList(AppConstants.UNREAD_FETCH_BATCH_SIZE);
List<String> hashSkips = new ArrayList(AppConstants.UNREAD_FETCH_BATCH_SIZE);
batchloop: for (String hash : StoryHashQueue) {
@ -156,7 +156,8 @@ public class UnreadsService extends SubService {
break unreadsyncloop;
}
parent.insertStories(response);
StateFilter stateFilter = PrefsUtils.getStateFilter(parent);
parent.insertStories(response, stateFilter);
for (String hash : hashBatch) {
StoryHashQueue.remove(hash);
}

View file

@ -0,0 +1,16 @@
package com.newsblur.util
import android.content.Context
data class CursorFilters(
val stateFilter: StateFilter,
val readFilter: ReadFilter,
val storyOrder: StoryOrder,
) {
constructor(context: Context, fs: FeedSet) : this(
stateFilter = PrefsUtils.getStateFilter(context),
readFilter = PrefsUtils.getReadFilter(context, fs),
storyOrder = PrefsUtils.getStoryOrder(context, fs),
)
}

View file

@ -20,6 +20,7 @@ fun <R> CoroutineScope.executeAsyncTask(
withContext(Dispatchers.Main) { onPostExecute(result) }
}
@JvmField
val NBScope = CoroutineScope(
CoroutineName(TAG) +
Dispatchers.Default +

View file

@ -34,12 +34,14 @@ class FeedUtils(
@JvmField
var currentFolderName: String? = null
fun prepareReadingSession(fs: FeedSet?, resetFirst: Boolean) {
fun prepareReadingSession(context: Context, fs: FeedSet?, resetFirst: Boolean) {
NBScope.executeAsyncTask(
doInBackground = {
try {
if (resetFirst) NBSyncService.resetReadingSession(dbHelper)
NBSyncService.prepareReadingSession(dbHelper, fs)
fs?.let {
NBSyncService.prepareReadingSession(context, dbHelper, it)
}
} catch (e: Exception) {
// this is a UI hinting call and might fail if the DB is being reset, but that is fine
}
@ -63,7 +65,7 @@ class FeedUtils(
NBScope.executeAsyncTask(
doInBackground = {
val ra = if (saved) ReadingAction.saveStory(storyHash, userTags) else ReadingAction.unsaveStory(storyHash)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_STORY)
dbHelper.enqueueAction(ra)
triggerSync(context)
@ -306,7 +308,7 @@ class FeedUtils(
NBScope.executeAsyncTask(
doInBackground = {
dbHelper.enqueueAction(ra)
val impact = ra.doLocal(dbHelper)
val impact = ra.doLocal(context, dbHelper)
syncUpdateStatus(context, impact)
triggerSync(context)
}
@ -347,7 +349,7 @@ class FeedUtils(
}
val ra = ReadingAction.shareStory(story.storyHash, story.id, story.feedId, sourceUserId, comment)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL or UPDATE_STORY)
triggerSync(context)
}
@ -355,7 +357,7 @@ class FeedUtils(
fun renameFeed(context: Context, feedId: String?, newFeedName: String?) {
val ra = ReadingAction.renameFeed(feedId, newFeedName)
dbHelper.enqueueAction(ra)
val impact = ra.doLocal(dbHelper)
val impact = ra.doLocal(context, dbHelper)
syncUpdateStatus(context, impact)
triggerSync(context)
}
@ -363,7 +365,7 @@ class FeedUtils(
fun unshareStory(story: Story, context: Context) {
val ra = ReadingAction.unshareStory(story.storyHash, story.id, story.feedId)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL or UPDATE_STORY)
triggerSync(context)
}
@ -371,7 +373,7 @@ class FeedUtils(
fun likeComment(story: Story, commentUserId: String?, context: Context) {
val ra = ReadingAction.likeComment(story.id, commentUserId, story.feedId)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL)
triggerSync(context)
}
@ -379,7 +381,7 @@ class FeedUtils(
fun unlikeComment(story: Story, commentUserId: String?, context: Context) {
val ra = ReadingAction.unlikeComment(story.id, commentUserId, story.feedId)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL)
triggerSync(context)
}
@ -387,7 +389,7 @@ class FeedUtils(
fun replyToComment(storyId: String?, feedId: String?, commentUserId: String?, replyText: String?, context: Context) {
val ra = ReadingAction.replyToComment(storyId, feedId, commentUserId, replyText)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL)
triggerSync(context)
}
@ -395,7 +397,7 @@ class FeedUtils(
fun updateReply(context: Context, story: Story, commentUserId: String?, replyId: String?, replyText: String?) {
val ra = ReadingAction.updateReply(story.id, story.feedId, commentUserId, replyId, replyText)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL)
triggerSync(context)
}
@ -403,7 +405,7 @@ class FeedUtils(
fun deleteReply(context: Context, story: Story, commentUserId: String?, replyId: String?) {
val ra = ReadingAction.deleteReply(story.id, story.feedId, commentUserId, replyId)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_SOCIAL)
triggerSync(context)
}
@ -448,7 +450,7 @@ class FeedUtils(
}
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_METADATA)
triggerSync(context)
@ -459,7 +461,7 @@ class FeedUtils(
fun instaFetchFeed(context: Context, feedId: String?) {
val ra = ReadingAction.instaFetch(feedId)
dbHelper.enqueueAction(ra)
ra.doLocal(dbHelper)
ra.doLocal(context, dbHelper)
syncUpdateStatus(context, UPDATE_METADATA)
triggerSync(context)
}

View file

@ -103,8 +103,6 @@ public class NotificationUtils {
}
}
// addAction deprecated in 23 but replacement not avail until 21
@SuppressWarnings("deprecation")
private static Notification buildStoryNotification(Story story, Cursor cursor, Context context, FileCache iconCache) {
Log.d(NotificationUtils.class.getName(), "Building notification");
Intent i = new Intent(context, FeedReading.class);

View file

@ -22,6 +22,8 @@ import android.graphics.BitmapFactory;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import android.util.Log;
@ -40,7 +42,7 @@ public class PrefsUtils {
private PrefsUtils() {} // util class - no instances
public static void saveCustomServer(Context context, String customServer) {
public static void saveCustomServer(Context context, @Nullable String customServer) {
if (customServer == null) return;
if (customServer.length() <= 0) return;
SharedPreferences preferences = context.getSharedPreferences(PrefConstants.PREFERENCES, 0);
@ -88,7 +90,7 @@ public class PrefsUtils {
}
public static void updateVersion(Context context, String appVersion) {
public static void updateVersion(Context context, @Nullable String appVersion) {
SharedPreferences prefs = context.getSharedPreferences(PrefConstants.PREFERENCES, 0);
// store the current version
prefs.edit().putString(AppConstants.LAST_APP_VERSION, appVersion).commit();
@ -96,6 +98,7 @@ public class PrefsUtils {
prefs.edit().putLong(AppConstants.LAST_SYNC_TIME, 0L).commit();
}
@Nullable
public static String getVersion(Context context) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
@ -105,7 +108,7 @@ public class PrefsUtils {
}
}
public static String createFeedbackLink(Context context, BlurDatabaseHelper dbHelper) {
public static String createFeedbackLink(Context context, @NonNull BlurDatabaseHelper dbHelper) {
StringBuilder s = new StringBuilder(AppConstants.FEEDBACK_URL);
s.append("<give us some feedback!>%0A%0A%0A");
String info = getDebugInfo(context, dbHelper);
@ -113,7 +116,7 @@ public class PrefsUtils {
return s.toString();
}
public static void sendLogEmail(Context context, BlurDatabaseHelper dbHelper) {
public static void sendLogEmail(Context context, @NonNull BlurDatabaseHelper dbHelper) {
File f = com.newsblur.util.Log.getLogfile();
if (f == null) return;
String debugInfo = "Tell us a bit about your problem:\n\n\n\n" + getDebugInfo(context, dbHelper);
@ -129,7 +132,7 @@ public class PrefsUtils {
}
}
private static String getDebugInfo(Context context, BlurDatabaseHelper dbHelper) {
private static String getDebugInfo(Context context, @NonNull BlurDatabaseHelper dbHelper) {
StringBuilder s = new StringBuilder();
s.append("app version: ").append(getVersion(context));
s.append("\n");
@ -167,7 +170,7 @@ public class PrefsUtils {
return s.toString();
}
public static void logout(Context context, BlurDatabaseHelper dbHelper) {
public static void logout(Context context, @NonNull BlurDatabaseHelper dbHelper) {
NBSyncService.softInterrupt();
NBSyncService.clearState();
@ -194,7 +197,7 @@ public class PrefsUtils {
context.startActivity(i);
}
public static void clearPrefsAndDbForLoginAs(Context context, BlurDatabaseHelper dbHelper) {
public static void clearPrefsAndDbForLoginAs(Context context, @NonNull BlurDatabaseHelper dbHelper) {
NBSyncService.softInterrupt();
NBSyncService.clearState();
@ -252,6 +255,7 @@ public class PrefsUtils {
saveUserImage(context, profile.photoUrl);
}
@Nullable
public static String getUserId(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PrefConstants.PREFERENCES, 0);
return preferences.getString(PrefConstants.USER_ID, null);
@ -282,7 +286,7 @@ public class PrefsUtils {
}
private static void saveUserImage(final Context context, String pictureUrl) {
Bitmap bitmap = null;
Bitmap bitmap;
try {
URL url = new URL(pictureUrl);
URLConnection connection;

View file

@ -6,6 +6,7 @@ import static com.newsblur.service.NBSyncReceiver.UPDATE_SOCIAL;
import static com.newsblur.service.NBSyncReceiver.UPDATE_STORY;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import java.io.Serializable;
@ -277,7 +278,7 @@ public class ReadingAction implements Serializable {
/**
* Execute this action remotely via the API.
*/
public NewsBlurResponse doRemote(APIManager apiManager, BlurDatabaseHelper dbHelper) {
public NewsBlurResponse doRemote(APIManager apiManager, BlurDatabaseHelper dbHelper, StateFilter stateFilter) {
// generic response to return
NewsBlurResponse result = null;
// optional specific responses that are locally actionable
@ -370,7 +371,7 @@ public class ReadingAction implements Serializable {
if (storiesResponse != null) {
result = storiesResponse;
if (storiesResponse.story != null) {
dbHelper.updateStory(storiesResponse, true);
dbHelper.updateStory(storiesResponse, stateFilter, true);
} else {
com.newsblur.util.Log.w(this, "failed to refresh story data after action");
}
@ -391,8 +392,8 @@ public class ReadingAction implements Serializable {
return result;
}
public int doLocal(BlurDatabaseHelper dbHelper) {
return doLocal(dbHelper, false);
public int doLocal(Context context, BlurDatabaseHelper dbHelper) {
return doLocal(context, dbHelper, false);
}
/**
@ -402,7 +403,8 @@ public class ReadingAction implements Serializable {
*
* @return the union of update impact flags that resulted from this action.
*/
public int doLocal(BlurDatabaseHelper dbHelper, boolean isFollowup) {
public int doLocal(Context context, BlurDatabaseHelper dbHelper, boolean isFollowup) {
String userId = PrefsUtils.getUserId(context);
int impact = 0;
switch (type) {
@ -434,32 +436,32 @@ public class ReadingAction implements Serializable {
case SHARE:
if (isFollowup) break; // shares are only placeholders
dbHelper.setStoryShared(storyHash, true);
dbHelper.insertCommentPlaceholder(storyId, feedId, commentReplyText);
dbHelper.setStoryShared(storyHash, userId, true);
dbHelper.insertCommentPlaceholder(storyId, userId, commentReplyText);
impact |= UPDATE_SOCIAL;
impact |= UPDATE_STORY;
break;
case UNSHARE:
dbHelper.setStoryShared(storyHash, false);
dbHelper.clearSelfComments(storyId);
dbHelper.setStoryShared(storyHash, userId, false);
dbHelper.clearSelfComments(storyId, userId);
impact |= UPDATE_SOCIAL;
impact |= UPDATE_STORY;
break;
case LIKE_COMMENT:
dbHelper.setCommentLiked(storyId, commentUserId, feedId, true);
dbHelper.setCommentLiked(storyId, commentUserId, userId, true);
impact |= UPDATE_SOCIAL;
break;
case UNLIKE_COMMENT:
dbHelper.setCommentLiked(storyId, commentUserId, feedId, false);
dbHelper.setCommentLiked(storyId, commentUserId, userId, false);
impact |= UPDATE_SOCIAL;
break;
case REPLY:
if (isFollowup) break; // replies are only placeholders
dbHelper.insertReplyPlaceholder(storyId, feedId, commentUserId, commentReplyText);
dbHelper.insertReplyPlaceholder(storyId, userId, commentUserId, commentReplyText);
break;
case EDIT_REPLY:

View file

@ -2,6 +2,7 @@ package com.newsblur.util;
import java.io.File;
import java.util.Map;
import java.util.Objects;
import static android.graphics.Bitmap.Config.ARGB_8888;
import static com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL;
@ -35,6 +36,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
@ -44,6 +46,7 @@ import androidx.core.content.ContextCompat;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.Snackbar;
import com.newsblur.NbApplication;
import com.newsblur.R;
import com.newsblur.activity.*;
@ -422,7 +425,7 @@ public class UIUtils {
* upon the provided classifier sub-type map while also setting up handlers to alter said
* map if the buttons are pressed.
*/
public static void setupIntelDialogRow(final View row, final Map<String,Integer> classifier, final String key) {
public static void setupIntelDialogRow(final View row, @NonNull final Map<String,Integer> classifier, final String key) {
colourIntelDialogRow(row, classifier, key);
row.findViewById(R.id.intel_row_like).setOnClickListener(v -> {
classifier.put(key, Classifier.LIKE);
@ -433,7 +436,11 @@ public class UIUtils {
colourIntelDialogRow(row, classifier, key);
});
row.findViewById(R.id.intel_row_clear).setOnClickListener(v -> {
classifier.put(key, Classifier.CLEAR_LIKE);
if (Objects.equals(classifier.get(key), Classifier.DISLIKE)) {
classifier.put(key, Classifier.CLEAR_DISLIKE);
} else {
classifier.put(key, Classifier.CLEAR_LIKE);
}
colourIntelDialogRow(row, classifier, key);
});
}
@ -599,4 +606,15 @@ public class UIUtils {
context.sendBroadcast(intent);
}
}
public static void showSnackBar(View view, String message) {
Snackbar.make(view, message, 600).show();
}
public static int[] getLoadingColorsArray(Context context) {
return new int[]{ContextCompat.getColor(context, R.color.refresh_1),
ContextCompat.getColor(context, R.color.refresh_2),
ContextCompat.getColor(context, R.color.refresh_3),
ContextCompat.getColor(context, R.color.refresh_4)};
}
}

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.newsblur.database.BlurDatabaseHelper
import com.newsblur.util.CursorFilters
import com.newsblur.util.FeedSet
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -21,9 +22,9 @@ class StoriesViewModel
private val _activeStoriesLiveData = MutableLiveData<Cursor>()
val activeStoriesLiveData: LiveData<Cursor> = _activeStoriesLiveData
fun getActiveStories(fs: FeedSet) {
fun getActiveStories(fs: FeedSet, cursorFilters: CursorFilters) {
viewModelScope.launch(Dispatchers.IO) {
dbHelper.getActiveStoriesCursor(fs, cancellationSignal).let {
dbHelper.getActiveStoriesCursor(fs, cursorFilters, cancellationSignal).let {
_activeStoriesLiveData.postValue(it)
}
}

View file

@ -149,9 +149,10 @@ class WidgetRemoteViewsFactory(context: Context, intent: Intent) : RemoteViewsFa
Log.d(this.javaClass.name, "onDataSetChanged - get remote stories")
val response = apiManager.getStories(fs, 1, StoryOrder.NEWEST, ReadFilter.ALL)
response.stories?.let {
val stateFilter = PrefsUtils.getStateFilter(context)
Log.d(this.javaClass.name, "onDataSetChanged - got ${it.size} remote stories")
processStories(response.stories)
dbHelper.insertStories(response, true)
dbHelper.insertStories(response, stateFilter, true)
} ?: Log.d(this.javaClass.name, "onDataSetChanged - null remote stories")
}
} catch (e: TimeoutCancellationException) {

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray55"
android:pathData="M19,18l2,1V3c0,-1.1 -0.9,-2 -2,-2H8.99C7.89,1 7,1.9 7,3h10c1.1,0 2,0.9 2,2v13zM15,5H5c-1.1,0 -2,0.9 -2,2v16l7,-3 7,3V7c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray55"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" />
</vector>

View file

@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray55"
android:pathData="M6.18,17.82m-2.18,0a2.18,2.18 0,1 1,4.36 0a2.18,2.18 0,1 1,-4.36 0" />
<path
android:fillColor="@color/gray55"
android:pathData="M4,4.44v2.83c7.03,0 12.73,5.7 12.73,12.73h2.83c0,-8.59 -6.97,-15.56 -15.56,-15.56zM4,10.1v2.83c3.9,0 7.07,3.17 7.07,7.07h2.83c0,-5.47 -4.43,-9.9 -9.9,-9.9z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray55"
android:pathData="M12,4L12,1L8,5l4,4L12,6c3.31,0 6,2.69 6,6 0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0,-4.42 -3.58,-8 -8,-8zM12,18c-3.31,0 -6,-2.69 -6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4 -4,-4v3z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/gray55"
android:pathData="M14,17L4,17v2h10v-2zM20,9L4,9v2h16L20,9zM4,15h16v-2L4,13v2zM4,5v2h16L20,5L4,5z" />
</vector>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true" >
android:animateLayoutChanges="true"
android:descendantFocusability="blocksDescendants">
<RelativeLayout
android:id="@+id/main_top_bar"
@ -103,7 +103,7 @@
to be defined first so that other things can be placed above it. -->
<fragment
android:id="@+id/fragment_feedintelligenceselector"
android:name="com.newsblur.fragment.FeedIntelligenceSelectorFragment"
android:name="com.newsblur.fragment.FeedSelectorFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
@ -175,7 +175,7 @@
android:imeOptions="actionSearch"
android:visibility="gone"
android:animateLayoutChanges="true"
/>
android:importantForAutofill="no" />
<!-- The scrollable and pull-able feed list. -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout

View file

@ -2,7 +2,8 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:descendantFocusability="blocksDescendants">
<include layout="@layout/toolbar_newsblur" />

View file

@ -41,7 +41,8 @@
android:hint="@string/share_comment_hint"
android:inputType="textCapSentences|textMultiLine"
android:singleLine="false"
android:textSize="15sp" />
android:textSize="15sp"
android:importantForAutofill="no" />
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/container_buttons"

View file

@ -6,17 +6,33 @@
android:animateLayoutChanges="true"
android:orientation="vertical">
<TextView
android:id="@+id/text_sync_status"
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/container_sync_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/status_overlay_background"
android:gravity="center"
android:padding="2dp"
android:text="@string/sync_status_feed_add"
android:textColor="@color/status_overlay_text"
android:textSize="14sp"
android:visibility="gone" />
android:orientation="horizontal"
android:paddingVertical="1dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/sync_status_feed_add"
android:textColor="@color/status_overlay_text"
android:textSize="14sp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
style="?circleProgressIndicator"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:indeterminate="true" />
</androidx.appcompat.widget.LinearLayoutCompat>
<TextView
android:id="@+id/text_add_folder_title"
@ -45,10 +61,10 @@
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:autofillHints="@null"
android:textSize="14sp"
android:hint="@string/new_folder_name_hint"
android:inputType="textCapSentences"
android:maxLines="1" />
android:maxLines="1"
android:textSize="14sp" />
<ImageView
android:id="@+id/ic_create_folder"

View file

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="25dp"
android:paddingBottom="16dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/feeds_shortcuts"
android:textSize="20sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_open_all_stories"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_open_all_stories_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_switch_views"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_switch_views_key_left"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_switch_views"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_switch_views_key_right"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_add_site"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_add_site_key"
android:textSize="15sp" />
</FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
</ScrollView>

View file

@ -24,6 +24,7 @@
android:id="@+id/login_username"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:hint="@string/login_username_hint"
android:inputType="textEmailAddress"
android:textSize="22sp" />
@ -33,6 +34,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:autofillHints="password"
android:hint="@string/login_password_hint"
android:imeOptions="actionDone"
android:inputType="textPassword"
@ -103,7 +105,8 @@
android:hint="@string/login_custom_server_hint"
android:inputType="textNoSuggestions|textMultiLine"
android:textSize="17sp"
android:visibility="invisible" />
android:visibility="invisible"
android:importantForAutofill="no" />
<TextView
android:id="@+id/button_reset_url"
@ -165,6 +168,7 @@
android:id="@+id/registration_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="username"
android:hint="@string/login_username_hint"
android:inputType="textEmailAddress"
android:textSize="22sp">
@ -176,6 +180,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:autofillHints="password"
android:hint="@string/login_password_hint"
android:inputType="textPassword"
android:nextFocusDown="@+id/registration_email"
@ -186,6 +191,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:autofillHints="emailAddress"
android:hint="@string/login_registration_email_hint"
android:imeOptions="actionDone"
android:inputType="textEmailAddress"

View file

@ -12,6 +12,7 @@
android:layout_marginBottom="10dp"
android:layout_marginTop="5dp"
android:singleLine="false"
android:autofillHints="username"
android:inputType="textCapSentences|textMultiLine" />
</RelativeLayout>

View file

@ -12,6 +12,7 @@
android:layout_marginBottom="10dp"
android:layout_marginTop="5dp"
android:singleLine="false"
android:inputType="textCapSentences|textMultiLine" />
android:inputType="textCapSentences|textMultiLine"
android:importantForAutofill="no" />
</RelativeLayout>

View file

@ -13,6 +13,7 @@
android:layout_marginTop="5dp"
android:singleLine="false"
android:inputType="textCapSentences|textMultiLine"
android:hint="@string/share_comment_hint"/>
android:hint="@string/share_comment_hint"
android:importantForAutofill="no" />
</RelativeLayout>

View file

@ -0,0 +1,397 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="25dp"
android:paddingBottom="16dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/stories_shortcuts"
android:textSize="20sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_next_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_next_story_key_j"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_next_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_next_story_key_down"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_previous_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_previous_story_key_k"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_previous_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_previous_story_key_up"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_text_view"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_text_view_key"
android:textSize="18sp"
android:textStyle="bold" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_page_down"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_page_down_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_page_up"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_page_up_key"
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_page_up_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_next_unread_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_next_unread_story_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_toggle_read_unread"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_toggle_read_unread_key_u"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_toggle_read_unread"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_toggle_read_unread_key_m"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_save_unsave_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_save_unsave_story_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_open_in_browser"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_open_in_browser_key_o"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_open_in_browser"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_open_in_browser_key_v"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:text="@string/short_share_this_story"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_share_story_key"
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_share_this_story_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_scroll_to_comments"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_scroll_to_comments_key"
android:textSize="15sp" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="@string/short_open_story_trainer"
android:textSize="15sp" />
<androidx.appcompat.widget.AppCompatTextView
style="?defaultText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/short_open_story_trainer_key"
android:textSize="15sp" />
</FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
</ScrollView>

View file

@ -31,6 +31,11 @@
android:title="@string/menu_newsletters"
app:showAsAction="never" />
<item android:id="@+id/menu_shortcuts"
android:title="@string/menu_shortcuts"
app:showAsAction="never"
android:visible="false" />
<item android:id="@+id/menu_text_size"
android:title="@string/menu_text_size" >
<menu>

View file

@ -22,6 +22,12 @@
app:showAsAction="never"
android:title="@string/menu_send_story_full"/>
<item
android:id="@+id/menu_shortcuts"
android:title="@string/menu_shortcuts"
android:visible="false"
app:showAsAction="never" />
<item android:id="@+id/menu_text_size"
android:title="@string/menu_text_size" >
<menu>

View file

@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="newsblur">NewsBlur</string>
@ -274,6 +274,9 @@
<string name="setup_instructions_details">To read your email newsletters in NewsBlur, forward your newsletters to your custom email address shown above.\n\nIn Gmail, go to Settings > Forwarding and click on Add a forwarding address. Add your custom NewsBlur email address.\n\nGmail will walk you through confirming the email address. You\'ll want to come back to NewsBlur and look for the confirmation email under the "Newsletters" folder.\n\nNext, create a filter with all of your newsletters so that they forward to the custom address on NewsBlur.</string>
<string name="copy_email">Copy email</string>
<string name="stories_shortcuts">Stories shortcuts</string>
<string name="feeds_shortcuts">Feeds shortcuts</string>
<string name="import_export">Import/Export…</string>
<string name="settings">Preferences…</string>
<string name="menu_mute_sites">Mute Sites…</string>
@ -285,6 +288,7 @@
<string name="title_no_subscriptions">No active subscriptions detected</string>
<string name="title_widget_loading">Loading…</string>
<string name="menu_newsletters">Newsletters…</string>
<string name="menu_shortcuts">Shortcuts…</string>
<string name="import_export_title">Import/Export OPML</string>
<string name="notifications_title">Notifications</string>
@ -577,7 +581,7 @@
<string name="sync_status_text">Storing text for %s stories…</string>
<string name="sync_status_images">Storing %s images…</string>
<string name="sync_status_offline">Offline</string>
<string name="sync_status_feed_add">Adding feed</string>
<string name="sync_status_feed_add">Adding feed</string>
<string name="volume_key_navigation">Volume key navigation…</string>
<string name="off">Off</string>
@ -739,4 +743,64 @@
<string name="notification_permissions_context">Permissions is required for posting notifications</string>
<string name="notification_permissions_rationale">Notifications permission must be added manually in the app\'s settings before trying again to enable notifications</string>
<string name="story_saved">Story marked as saved</string>
<string name="story_unsaved">Story marked as unsaved</string>
<string name="story_read">Story marked as read</string>
<string name="story_unread">Story marked as unread</string>
<string name="unread_stories">Unread stories</string>
<string name="focused_stories">Focused stories</string>
<string name="saved_stories">Saved stories</string>
<string name="short_next_story">Next Story</string>
<string name="short_next_story_key_down">\u2193</string>
<string name="short_next_story_key_j">J</string>
<string name="short_previous_story">Previous Story</string>
<string name="short_previous_story_key_up">\u2191</string>
<string name="short_previous_story_key_k">K</string>
<string name="short_text_view">Text View</string>
<string name="short_text_view_key">\u21E7 \u23CE</string>
<string name="short_page_down">Page Down</string>
<string name="short_page_down_key">space</string>
<string name="short_page_up">Page Up</string>
<string name="short_page_up_key">\u21E7 space</string>
<string name="short_next_unread_story">Next Unread Story</string>
<string name="short_next_unread_story_key">N</string>
<string name="short_toggle_read_unread">Toggle Read/Unread</string>
<string name="short_toggle_read_unread_key_u">U</string>
<string name="short_toggle_read_unread_key_m">M</string>
<string name="short_save_unsave_story">Save/Unsave Story</string>
<string name="short_save_unsave_story_key">S</string>
<string name="short_open_in_browser">Open in Browser</string>
<string name="short_open_in_browser_key_o">O</string>
<string name="short_open_in_browser_key_v">V</string>
<string name="short_share_this_story">Share this Story</string>
<string name="short_share_this_story_key">\u21E7 S</string>
<string name="short_scroll_to_comments">Scroll to Comments</string>
<string name="short_scroll_to_comments_key">C</string>
<string name="short_open_story_trainer">Open Story Trainer</string>
<string name="short_open_story_trainer_key">T</string>
<string name="short_open_all_stories">Open All Stories</string>
<string name="short_open_all_stories_key">\u2325 E</string>
<string name="short_switch_views">Switch Views</string>
<string name="short_switch_views_key_left">\u2190</string>
<string name="short_switch_views_key_right">\u2192</string>
<string name="short_add_site">Add Site</string>
<string name="short_add_site_key">\u2325 A</string>
</resources>

View file

@ -46,6 +46,7 @@
<item name="android:background">@color/bar_background</item>
<item name="android:textColor">@color/gray30</item>
</style>
<style name="subscriptionHeader.dark">
<item name="android:background">@color/dark_bar_background</item>
<item name="android:textColor">@color/gray55</item>
@ -54,6 +55,7 @@
<style name="subscriptionIcon">
<item name="tint">@color/gray30</item>
</style>
<style name="subscriptionIcon.dark">
<item name="tint">@color/gray55</item>
</style>
@ -540,4 +542,24 @@
<item name="android:layout_height">40dp</item>
</style>
<style name="materialSnackBarTextView" parent="@style/Widget.MaterialComponents.Snackbar.TextView">
<item name="android:textStyle">bold</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/text</item>
</style>
<style name="materialSnackBarTextView.dark" parent="@style/Widget.MaterialComponents.Snackbar.TextView">
<item name="android:textStyle">bold</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/white</item>
</style>
<style name="materialSnackBarTheme" parent="@style/Widget.MaterialComponents.Snackbar">
<item name="android:background">@color/nb_green_gray91</item>
</style>
<style name="materialSnackBarTheme.dark" parent="@style/Widget.MaterialComponents.Snackbar">
<item name="android:background">@color/gray13</item>
</style>
</resources>

View file

@ -61,6 +61,8 @@
<item name="fontFamily">@font/whitney</item>
<item name="circleProgressIndicator">@style/circleProgressIndicator</item>
<item name="toggleButton">@style/toggleButton</item>
<item name="snackbarStyle">@style/materialSnackBarTheme</item>
<item name="snackbarTextViewStyle">@style/materialSnackBarTextView</item>
</style>
<style name="NewsBlurDarkTheme" parent="Theme.MaterialComponents.NoActionBar">
@ -124,6 +126,8 @@
<item name="fontFamily">@font/whitney</item>
<item name="circleProgressIndicator">@style/circleProgressIndicator</item>
<item name="toggleButton">@style/toggleButton.dark</item>
<item name="snackbarStyle">@style/materialSnackBarTheme.dark</item>
<item name="snackbarTextViewStyle">@style/materialSnackBarTextView.dark</item>
</style>
<style name="NewsBlurBlackTheme" parent="Theme.MaterialComponents.NoActionBar" >
@ -187,6 +191,8 @@
<item name="fontFamily">@font/whitney</item>
<item name="circleProgressIndicator">@style/circleProgressIndicator</item>
<item name="toggleButton">@style/toggleButton.dark</item>
<item name="snackbarStyle">@style/materialSnackBarTheme.dark</item>
<item name="snackbarTextViewStyle">@style/materialSnackBarTextView.dark</item>
</style>
<style name="NewsBlurTheme.Translucent" parent="NewsBlurTheme">

View file

@ -0,0 +1,90 @@
package com.newsblur
import android.view.KeyEvent
import com.newsblur.keyboard.KeyboardEvent
import com.newsblur.keyboard.KeyboardListener
import com.newsblur.keyboard.KeyboardManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.After
import org.junit.Assert
import org.junit.Test
class HomeKeyboardShortcutsTest {
private val manager = KeyboardManager()
@After
fun afterTest() {
manager.removeListener()
}
@Test
fun openAllStoriesTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isAltPressed } returns true
val handled = manager.onKeyUp(KeyEvent.KEYCODE_E, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.OpenAllStories) }
}
@Test
fun notOpenAllStoriesTest() {
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isAltPressed } returns false
val handled = manager.onKeyUp(KeyEvent.KEYCODE_E, keyEvent)
Assert.assertFalse(handled)
}
@Test
fun addSiteTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isAltPressed } returns true
val handled = manager.onKeyUp(KeyEvent.KEYCODE_A, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.AddFeed) }
}
@Test
fun notAddSiteTest() {
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isAltPressed } returns false
val handled = manager.onKeyUp(KeyEvent.KEYCODE_A, keyEvent)
Assert.assertFalse(handled)
}
@Test
fun switchViewLeftTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_DPAD_LEFT, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.SwitchViewLeft) }
}
@Test
fun switchViewRightTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_DPAD_RIGHT, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.SwitchViewRight) }
}
}

View file

@ -0,0 +1,222 @@
package com.newsblur
import android.view.KeyEvent
import com.newsblur.keyboard.KeyboardEvent
import com.newsblur.keyboard.KeyboardListener
import com.newsblur.keyboard.KeyboardManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.After
import org.junit.Assert
import org.junit.Test
class ReadingKeyboardShortcutsTest {
private val manager = KeyboardManager()
@After
fun afterTest() {
manager.removeListener()
}
@Test
fun previousStoryTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_J, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.PreviousStory) }
}
@Test
fun previousStoryArrowTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_DPAD_DOWN, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.PreviousStory) }
}
@Test
fun nextStoryTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_K, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.NextStory) }
}
@Test
fun nextStoryArrowTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_DPAD_UP, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.NextStory) }
}
@Test
fun toggleTextViewTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns true
val handled = manager.onKeyUp(KeyEvent.KEYCODE_ENTER, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.ToggleTextView) }
}
@Test
fun noToggleTextViewTest() {
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns false
val handled = manager.onKeyUp(KeyEvent.KEYCODE_ENTER, keyEvent)
Assert.assertFalse(handled)
}
@Test
fun pageDownTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns false
val handled = manager.onKeyUp(KeyEvent.KEYCODE_SPACE, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.PageDown) }
}
@Test
fun pageUpTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns true
val handled = manager.onKeyUp(KeyEvent.KEYCODE_SPACE, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.PageUp) }
}
@Test
fun nextUnreadStoryTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_N, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.NextUnreadStory) }
}
@Test
fun toggleReadUnreadUTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_U, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.ToggleReadUnread) }
}
@Test
fun toggleReadUnreadMTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_M, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.ToggleReadUnread) }
}
@Test
fun saveUnsaveStoryTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns false
val handled = manager.onKeyUp(KeyEvent.KEYCODE_S, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.SaveUnsaveStory) }
}
@Test
fun shareStoryTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val keyEvent = mockk<KeyEvent>()
every { keyEvent.isShiftPressed } returns true
val handled = manager.onKeyUp(KeyEvent.KEYCODE_S, keyEvent)
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.ShareStory) }
}
@Test
fun openInBrowserOTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_O, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.OpenInBrowser) }
}
@Test
fun openInBrowserVTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_V, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.OpenInBrowser) }
}
@Test
fun scrollToCommentsTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_C, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.ScrollToComments) }
}
@Test
fun openStoryTrainerTest() {
val listener = mockk<KeyboardListener>()
every { listener.onKeyboardEvent(any()) } returns Unit
manager.addListener(listener)
val handled = manager.onKeyUp(KeyEvent.KEYCODE_T, mockk())
Assert.assertTrue(handled)
verify { listener.onKeyboardEvent(KeyboardEvent.OpenStoryTrainer) }
}
}

View file

@ -1,22 +0,0 @@
plugins {
id 'com.android.application' version '7.4.0' apply false
id 'com.android.library' version '7.4.0' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'org.jetbrains.kotlin.kapt' version '1.7.20' apply false
id 'com.google.dagger.hilt.android' version '2.44.2' apply false
id 'com.android.test' version '7.4.0' apply false
}
allprojects {
repositories {
mavenCentral()
maven {
url 'https://maven.google.com'
}
google()
}
}
task clear(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,19 @@
plugins {
id(Plugins.androidApplication) version Version.android apply false
id(Plugins.androidLibrary) version Version.android apply false
kotlin(Plugins.kotlinAndroid) version Version.kotlin apply false
kotlin(Plugins.kotlinKapt) version Version.kotlin apply false
id(Plugins.hiltAndroid) version Version.hilt apply false
id(Plugins.androidTest) version Version.android apply false
}
allprojects {
repositories {
mavenCentral()
google()
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}

View file

@ -0,0 +1,9 @@
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
google()
mavenCentral()
}

View file

@ -0,0 +1,14 @@
import org.gradle.api.JavaVersion
object Config {
const val compileSdk = 33
const val minSdk = 23
const val targetSdk = 33
const val versionCode = 213
const val versionName = "13.1.0"
const val androidTestInstrumentation = "androidx.test.runner.AndroidJUnitRunner"
val javaVersion = JavaVersion.VERSION_17
}

View file

@ -0,0 +1,15 @@
object Const {
const val namespace = "com.newsblur"
const val namespaceBenchmark = "com.newsblur.benchmark"
const val release = "release"
const val debug = "debug"
const val benchmark = "benchmark"
const val selfInstrumenting = "android.experimental.self-instrumenting"
const val benchmarkProguard = "benchmark-rules.pro"
const val appProguard = "proguard-rules.pro"
const val defaultProguard = "proguard-android.txt"
}

View file

@ -0,0 +1,30 @@
object Dependencies {
const val fragment = "androidx.fragment:fragment-ktx:${Version.fragment}"
const val recyclerView = "androidx.recyclerview:recyclerview:${Version.recyclerView}"
const val swipeRefreshLayout = "androidx.swiperefreshlayout:swiperefreshlayout:${Version.swipeRefreshLayout}"
const val okHttp = "com.squareup.okhttp3:okhttp:${Version.okHttp}"
const val gson = "com.google.code.gson:gson:${Version.gson}"
const val billing = "com.android.billingclient:billing:${Version.billing}"
const val playCore = "com.google.android.play:core:${Version.playCore}"
const val material = "com.google.android.material:material:${Version.material}"
const val preference = "androidx.preference:preference-ktx:${Version.preference}"
const val browser = "androidx.browser:browser:${Version.browser}"
const val lifecycleRuntime = "androidx.lifecycle:lifecycle-runtime-ktx:${Version.lifecycle}"
const val lifecycleProcess = "androidx.lifecycle:lifecycle-process:${Version.lifecycle}"
const val splashScreen = "androidx.core:core-splashscreen:${Version.splashScreen}"
const val hiltAndroid = "com.google.dagger:hilt-android:${Version.hilt}"
const val hiltCompiler = "com.google.dagger:hilt-compiler:${Version.hilt}"
const val profileInstaller = "androidx.profileinstaller:profileinstaller:${Version.profileInstaller}"
// test
const val junit = "junit:junit:${Version.junit}"
const val mockk = "io.mockk:mockk:${Version.mockk}"
// android test
const val junitExt = "androidx.test.ext:junit:${Version.junitExt}"
const val espressoCore = "androidx.test.espresso:espresso-core:${Version.espresso}"
const val uiAutomator = "androidx.test.uiautomator:uiautomator:${Version.uiAutomator}"
const val benchmarkMacroJunit4 = "androidx.benchmark:benchmark-macro-junit4:${Version.benchmarkMacroJunit4}"
}

View file

@ -0,0 +1,16 @@
object Plugins {
const val kotlinAndroid = "android"
const val kotlinKapt = "kapt"
const val androidApplication = "com.android.application"
const val androidLibrary = "com.android.library"
const val hiltAndroid = "com.google.dagger.hilt.android"
const val androidTest = "com.android.test"
// id("com.android.application") version "8.1.0" apply false
// id("com.android.library") version "8.1.0" apply false
// id("org.jetbrains.kotlin.android") version "1.8.10" apply false
// id("org.jetbrains.kotlin.kapt") version "1.8.10" apply false
// id("com.google.dagger.hilt.android") version "2.44.2" apply false
// id("com.android.test") version "8.1.0" apply false
}

View file

@ -0,0 +1,33 @@
object Version {
const val android = "8.1.0"
const val kotlin = "1.8.10"
const val fragment = "1.6.1"
const val recyclerView = "1.3.1"
const val swipeRefreshLayout = "1.1.0"
const val okHttp = "4.11.0"
const val gson = "2.10"
const val billing = "6.0.1"
const val playCore = "1.10.3"
const val material = "1.9.0"
const val preference = "1.2.0"
const val browser = "1.5.0"
const val lifecycle = "2.6.1"
const val splashScreen = "1.0.1"
const val hilt = "2.44.2"
const val profileInstaller = "1.3.1"
const val junit = "4.13.2"
const val mockk = "1.13.4"
const val junitExt = "1.1.5"
const val espresso = "3.5.1"
const val uiAutomator = "2.2.0"
const val benchmarkMacroJunit4 = "1.1.1"
}

View file

@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M"
kotlin.code.style=obsolete
android.useAndroidX=true
android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true

View file

@ -1,6 +1,6 @@
#Thu Oct 27 16:20:00 PDT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View file

@ -6,5 +6,5 @@ pluginManagement {
}
}
rootProject.name = "NewsBlur"
include ':app'
include ':app:benchmark'
include("app")
include("app:benchmark")