diff --git a/.github/workflows/android-actions.yml b/.github/workflows/android-actions.yml index e524cbc76..eb7506234 100644 --- a/.github/workflows/android-actions.yml +++ b/.github/workflows/android-actions.yml @@ -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 diff --git a/clients/android/NewsBlur/app/benchmark/build.gradle b/clients/android/NewsBlur/app/benchmark/build.gradle deleted file mode 100644 index c8c6a3ccb..000000000 --- a/clients/android/NewsBlur/app/benchmark/build.gradle +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/benchmark/build.gradle.kts b/clients/android/NewsBlur/app/benchmark/build.gradle.kts new file mode 100644 index 000000000..6e22b1e55 --- /dev/null +++ b/clients/android/NewsBlur/app/benchmark/build.gradle.kts @@ -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 + } +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/build.gradle b/clients/android/NewsBlur/app/build.gradle deleted file mode 100644 index 7d6f69716..000000000 --- a/clients/android/NewsBlur/app/build.gradle +++ /dev/null @@ -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' -} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/build.gradle.kts b/clients/android/NewsBlur/app/build.gradle.kts new file mode 100644 index 000000000..5b640ed6b --- /dev/null +++ b/clients/android/NewsBlur/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/proguard-rules.pro b/clients/android/NewsBlur/app/proguard-rules.pro index e0b469687..b35ca3e41 100644 --- a/clients/android/NewsBlur/app/proguard-rules.pro +++ b/clients/android/NewsBlur/app/proguard-rules.pro @@ -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 diff --git a/clients/android/NewsBlur/app/src/main/AndroidManifest.xml b/clients/android/NewsBlur/app/src/main/AndroidManifest.xml index 91a01af45..db68547ee 100644 --- a/clients/android/NewsBlur/app/src/main/AndroidManifest.xml +++ b/clients/android/NewsBlur/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/FeedChooser.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/FeedChooser.java index 9f4a0cc4a..7a9167521 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/FeedChooser.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/FeedChooser.java @@ -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); } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/ItemsList.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/ItemsList.java index cc1252652..1a7c5f0a8 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/ItemsList.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/ItemsList.java @@ -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(); diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Main.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Main.java index ca80a55de..bfd70a448 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Main.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Main.java @@ -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(); + } + } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/MuteConfig.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/MuteConfig.java index 5c9677e52..3d3889632 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/MuteConfig.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/MuteConfig.java @@ -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); } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Reading.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Reading.kt index 2af892d3a..b264695db 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Reading.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/Reading.kt @@ -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() + 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" diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/WidgetConfig.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/WidgetConfig.java index 84d886419..91fb88b72 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/WidgetConfig.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/activity/WidgetConfig.java @@ -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); } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/BlurDatabaseHelper.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/BlurDatabaseHelper.java index 8865d8aaf..0bc123d80 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/BlurDatabaseHelper.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/BlurDatabaseHelper.java @@ -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 newIds = new HashSet(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 newIds = new HashSet(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) { diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/StoryViewAdapter.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/StoryViewAdapter.java index acac278dc..f3ebc2473 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/StoryViewAdapter.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/database/StoryViewAdapter.java @@ -413,52 +413,42 @@ public class StoryViewAdapter extends RecyclerView.Adapter 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 } } \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/domain/Story.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/domain/Story.java index 2768d3b57..c9983da7e 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/domain/Story.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/domain/Story.java @@ -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; } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/AddFeedFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/AddFeedFragment.kt index 472f6e7b9..0f0ff41c5 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/AddFeedFragment.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/AddFeedFragment.kt @@ -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() { + private class AddFeedAdapter(private val listener: OnFolderClickListener) : RecyclerView.Adapter() { private val folders: MutableList = 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) { diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedIntelligenceSelectorFragment.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedSelectorFragment.java similarity index 89% rename from clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedIntelligenceSelectorFragment.java rename to clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedSelectorFragment.java index f95d364e2..2ba596652 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedIntelligenceSelectorFragment.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedSelectorFragment.java @@ -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); } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedsShortcutFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedsShortcutFragment.kt new file mode 100644 index 000000000..db98fdb6c --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/FeedsShortcutFragment.kt @@ -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() + } +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ItemSetFragment.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ItemSetFragment.java index ec59c3e61..e07f990db 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ItemSetFragment.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ItemSetFragment.java @@ -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) { diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/NewslettersFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/NewslettersFragment.kt index 52776acc9..cc920f37a 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/NewslettersFragment.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/NewslettersFragment.kt @@ -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 diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ProfileActivityDetailsFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ProfileActivityDetailsFragment.kt index 5b4e1c7fc..28ce4b38e 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ProfileActivityDetailsFragment.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ProfileActivityDetailsFragment.kt @@ -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 diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ReadingItemFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ReadingItemFragment.kt index 909b1e6a3..2b5c50ef3 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ReadingItemFragment.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/ReadingItemFragment.kt @@ -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 { diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryIntelTrainerFragment.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryIntelTrainerFragment.java index af1b966b4..40f4f0638 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryIntelTrainerFragment.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryIntelTrainerFragment.java @@ -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 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(); } }); diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryShortcutsFragment.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryShortcutsFragment.kt new file mode 100644 index 000000000..05327dfeb --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/fragment/StoryShortcutsFragment.kt @@ -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) + } +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardEvent.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardEvent.kt new file mode 100644 index 000000000..8dd28cd6a --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardEvent.kt @@ -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() +} diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardManager.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardManager.kt new file mode 100644 index 000000000..4464cce5b --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/keyboard/KeyboardManager.kt @@ -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 true to prevent this event from being propagated + * further, or false 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 + } +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIManager.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIManager.java index e4a0589c6..443640589 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIManager.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIManager.java @@ -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 parameters = new ArrayList(); + List parameters = new ArrayList<>(); for (Entry 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); } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIResponse.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIResponse.java index 0192f2bee..2a955b2f3 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIResponse.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/network/APIResponse.java @@ -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 getResponse(Gson gson, Class classOfT) { if (this.isError) { // if we encountered an error, make a generic response type and populate diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/CleanupService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/CleanupService.java index df2c4b63f..116205633 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/CleanupService.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/CleanupService.java @@ -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 diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/ImagePrefetchService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/ImagePrefetchService.java index b5554a315..31ae9d11e 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/ImagePrefetchService.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/ImagePrefetchService.java @@ -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 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 diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/NBSyncService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/NBSyncService.java index c61782009..bc3b6ca9c 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/NBSyncService.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/NBSyncService.java @@ -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); } } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/OriginalTextService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/OriginalTextService.java index 86c109041..b54a04b18 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/OriginalTextService.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/OriginalTextService.java @@ -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("]*src=(['\"])((?:(?!\\1).)*)\\1[^>]*>", Pattern.CASE_INSENSITIVE); /** story hashes we need to fetch (from newly found stories) */ - private static Set Hashes; - static {Hashes = new HashSet();} + private static final Set Hashes = new HashSet<>(); /** story hashes we should fetch ASAP (they are waiting on-screen) */ - private static Set PriorityHashes; - static {PriorityHashes = new HashSet();} + private static final Set PriorityHashes = new HashSet<>(); public OriginalTextService(NBSyncService parent) { - super(parent); + super(parent, ExtensionsKt.NBScope); } @Override diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.java deleted file mode 100644 index 1d6d45cc2..000000000 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.java +++ /dev/null @@ -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()); - } - - 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(); - } - } - } - - - -} - diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.kt new file mode 100644 index 000000000..3c051a44d --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubService.kt @@ -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 +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubscriptionSyncService.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubscriptionSyncService.kt index b8540aafc..f8d10e1aa 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubscriptionSyncService.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/SubscriptionSyncService.kt @@ -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 diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/UnreadsService.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/UnreadsService.java index 0d321fc0f..5c1c17882 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/UnreadsService.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/service/UnreadsService.java @@ -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(); } 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 hashBatch = new ArrayList(AppConstants.UNREAD_FETCH_BATCH_SIZE); List 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); } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/CursorFilters.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/CursorFilters.kt new file mode 100644 index 000000000..57bf46045 --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/CursorFilters.kt @@ -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), + ) +} \ No newline at end of file diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/Extensions.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/Extensions.kt index 92c64d9a2..19dd5f333 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/Extensions.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/Extensions.kt @@ -20,6 +20,7 @@ fun CoroutineScope.executeAsyncTask( withContext(Dispatchers.Main) { onPostExecute(result) } } +@JvmField val NBScope = CoroutineScope( CoroutineName(TAG) + Dispatchers.Default + diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/FeedUtils.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/FeedUtils.kt index 0a9dfb014..4cb1fcc99 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/FeedUtils.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/FeedUtils.kt @@ -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) } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/NotificationUtils.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/NotificationUtils.java index 380aa1ba0..3de853349 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/NotificationUtils.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/NotificationUtils.java @@ -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); diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/PrefsUtils.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/PrefsUtils.java index 790b99b96..50e041b2a 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/PrefsUtils.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/PrefsUtils.java @@ -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("%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; diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/ReadingAction.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/ReadingAction.java index 08c5b632a..d447e82ea 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/ReadingAction.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/ReadingAction.java @@ -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: diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/UIUtils.java b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/UIUtils.java index 3f85e59fe..3c51910b3 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/UIUtils.java +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/util/UIUtils.java @@ -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 classifier, final String key) { + public static void setupIntelDialogRow(final View row, @NonNull final Map 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)}; + } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/viewModel/StoriesViewModel.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/viewModel/StoriesViewModel.kt index 426d3252e..876de8a26 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/viewModel/StoriesViewModel.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/viewModel/StoriesViewModel.kt @@ -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() val activeStoriesLiveData: LiveData = _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) } } diff --git a/clients/android/NewsBlur/app/src/main/java/com/newsblur/widget/WidgetRemoteViewsFactory.kt b/clients/android/NewsBlur/app/src/main/java/com/newsblur/widget/WidgetRemoteViewsFactory.kt index 393e8c9c7..699a0a3b8 100644 --- a/clients/android/NewsBlur/app/src/main/java/com/newsblur/widget/WidgetRemoteViewsFactory.kt +++ b/clients/android/NewsBlur/app/src/main/java/com/newsblur/widget/WidgetRemoteViewsFactory.kt @@ -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) { diff --git a/clients/android/NewsBlur/app/src/main/res/drawable/ic_bookmark.xml b/clients/android/NewsBlur/app/src/main/res/drawable/ic_bookmark.xml deleted file mode 100644 index a9d4765ff..000000000 --- a/clients/android/NewsBlur/app/src/main/res/drawable/ic_bookmark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/clients/android/NewsBlur/app/src/main/res/drawable/ic_lock.xml b/clients/android/NewsBlur/app/src/main/res/drawable/ic_lock.xml deleted file mode 100644 index 658e34b2d..000000000 --- a/clients/android/NewsBlur/app/src/main/res/drawable/ic_lock.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/clients/android/NewsBlur/app/src/main/res/drawable/ic_rss_feed.xml b/clients/android/NewsBlur/app/src/main/res/drawable/ic_rss_feed.xml deleted file mode 100644 index c5813bda8..000000000 --- a/clients/android/NewsBlur/app/src/main/res/drawable/ic_rss_feed.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/clients/android/NewsBlur/app/src/main/res/drawable/ic_sync.xml b/clients/android/NewsBlur/app/src/main/res/drawable/ic_sync.xml deleted file mode 100644 index a1e783529..000000000 --- a/clients/android/NewsBlur/app/src/main/res/drawable/ic_sync.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/clients/android/NewsBlur/app/src/main/res/drawable/ic_text.xml b/clients/android/NewsBlur/app/src/main/res/drawable/ic_text.xml deleted file mode 100644 index fcaac987a..000000000 --- a/clients/android/NewsBlur/app/src/main/res/drawable/ic_text.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/clients/android/NewsBlur/app/src/main/res/layout/activity_main.xml b/clients/android/NewsBlur/app/src/main/res/layout/activity_main.xml index 5f7e9a5e2..ea3daa559 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/activity_main.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/activity_main.xml @@ -1,10 +1,10 @@ - + android:animateLayoutChanges="true" + android:descendantFocusability="blocksDescendants"> + android:importantForAutofill="no" /> + android:layout_height="match_parent" + android:descendantFocusability="blocksDescendants"> diff --git a/clients/android/NewsBlur/app/src/main/res/layout/activity_share_external_story.xml b/clients/android/NewsBlur/app/src/main/res/layout/activity_share_external_story.xml index 6291b7310..932a5d8ee 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/activity_share_external_story.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/activity_share_external_story.xml @@ -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" /> - + android:orientation="horizontal" + android:paddingVertical="1dp" + android:visibility="gone"> + + + + + + + android:maxLines="1" + android:textSize="14sp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/android/NewsBlur/app/src/main/res/layout/fragment_loginregister.xml b/clients/android/NewsBlur/app/src/main/res/layout/fragment_loginregister.xml index 34ff69fca..8c714b27f 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/fragment_loginregister.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/fragment_loginregister.xml @@ -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" /> @@ -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" diff --git a/clients/android/NewsBlur/app/src/main/res/layout/loginas_dialog.xml b/clients/android/NewsBlur/app/src/main/res/layout/loginas_dialog.xml index 1ed046f95..55c7cd68f 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/loginas_dialog.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/loginas_dialog.xml @@ -12,6 +12,7 @@ android:layout_marginBottom="10dp" android:layout_marginTop="5dp" android:singleLine="false" + android:autofillHints="username" android:inputType="textCapSentences|textMultiLine" /> diff --git a/clients/android/NewsBlur/app/src/main/res/layout/reply_dialog.xml b/clients/android/NewsBlur/app/src/main/res/layout/reply_dialog.xml index 46678e06a..5713373ee 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/reply_dialog.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/reply_dialog.xml @@ -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" /> diff --git a/clients/android/NewsBlur/app/src/main/res/layout/share_dialog.xml b/clients/android/NewsBlur/app/src/main/res/layout/share_dialog.xml index fb85d7820..c402cc104 100644 --- a/clients/android/NewsBlur/app/src/main/res/layout/share_dialog.xml +++ b/clients/android/NewsBlur/app/src/main/res/layout/share_dialog.xml @@ -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" /> diff --git a/clients/android/NewsBlur/app/src/main/res/layout/story_shortcuts_dialog.xml b/clients/android/NewsBlur/app/src/main/res/layout/story_shortcuts_dialog.xml new file mode 100644 index 000000000..c18de39d0 --- /dev/null +++ b/clients/android/NewsBlur/app/src/main/res/layout/story_shortcuts_dialog.xml @@ -0,0 +1,397 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/android/NewsBlur/app/src/main/res/menu/main.xml b/clients/android/NewsBlur/app/src/main/res/menu/main.xml index e9c01b871..b416fab92 100644 --- a/clients/android/NewsBlur/app/src/main/res/menu/main.xml +++ b/clients/android/NewsBlur/app/src/main/res/menu/main.xml @@ -31,6 +31,11 @@ android:title="@string/menu_newsletters" app:showAsAction="never" /> + + diff --git a/clients/android/NewsBlur/app/src/main/res/menu/story_context.xml b/clients/android/NewsBlur/app/src/main/res/menu/story_context.xml index c81b2974b..253ef13af 100644 --- a/clients/android/NewsBlur/app/src/main/res/menu/story_context.xml +++ b/clients/android/NewsBlur/app/src/main/res/menu/story_context.xml @@ -22,6 +22,12 @@ app:showAsAction="never" android:title="@string/menu_send_story_full"/> + + diff --git a/clients/android/NewsBlur/app/src/main/res/values/strings.xml b/clients/android/NewsBlur/app/src/main/res/values/strings.xml index 3f0be3cca..937bca0b9 100644 --- a/clients/android/NewsBlur/app/src/main/res/values/strings.xml +++ b/clients/android/NewsBlur/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + NewsBlur @@ -274,6 +274,9 @@ 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. Copy email + Stories shortcuts + Feeds shortcuts + Import/Export… Preferences… Mute Sites… @@ -285,6 +288,7 @@ No active subscriptions detected Loading… Newsletters… + Shortcuts… Import/Export OPML Notifications @@ -577,7 +581,7 @@ Storing text for %s stories… Storing %s images… Offline - Adding feed … + Adding feed Volume key navigation… Off @@ -739,4 +743,64 @@ Permissions is required for posting notifications Notifications permission must be added manually in the app\'s settings before trying again to enable notifications + + Story marked as saved + Story marked as unsaved + Story marked as read + Story marked as unread + + Unread stories + Focused stories + Saved stories + + Next Story + \u2193 + J + + Previous Story + \u2191 + K + + Text View + \u21E7 \u23CE + + Page Down + space + + Page Up + \u21E7 space + + Next Unread Story + N + + Toggle Read/Unread + U + M + + Save/Unsave Story + S + + Open in Browser + O + V + + Share this Story + \u21E7 S + + Scroll to Comments + C + + Open Story Trainer + T + + Open All Stories + \u2325 E + + Switch Views + \u2190 + \u2192 + + Add Site + \u2325 A + diff --git a/clients/android/NewsBlur/app/src/main/res/values/styles.xml b/clients/android/NewsBlur/app/src/main/res/values/styles.xml index 5f1a41c64..9be5daad0 100644 --- a/clients/android/NewsBlur/app/src/main/res/values/styles.xml +++ b/clients/android/NewsBlur/app/src/main/res/values/styles.xml @@ -46,6 +46,7 @@ @color/bar_background @color/gray30 + + @@ -540,4 +542,24 @@ 40dp + + + + + + + + diff --git a/clients/android/NewsBlur/app/src/main/res/values/theme.xml b/clients/android/NewsBlur/app/src/main/res/values/theme.xml index 79b7b99f7..49f5618f9 100644 --- a/clients/android/NewsBlur/app/src/main/res/values/theme.xml +++ b/clients/android/NewsBlur/app/src/main/res/values/theme.xml @@ -61,6 +61,8 @@ @font/whitney @style/circleProgressIndicator @style/toggleButton + @style/materialSnackBarTheme + @style/materialSnackBarTextView