Compare commits

...

22 Commits

Author SHA1 Message Date
cf6ef51edf Changelog for v125030901 2025-03-31 18:44:08 +00:00
0b7f832573 Merge pull request 'fix-reload' (#195) from fix-reload into master
All checks were successful
Master / build (push) Successful in 11m29s
Realease / build (push) Successful in 5m52s
Realease / createTagAndChangelog (push) Successful in 45s
Realease / release (push) Successful in 7m23s
Reviewed-on: #195
2025-03-31 18:25:33 +00:00
22c966bf16 fix: Infinite scroll needs loading stats.
All checks were successful
PR / translations (pull_request) Successful in 31s
PR / PR (pull_request) Successful in 41s
PR / build (pull_request) Successful in 16m2s
PR test / integrationTests (pull_request) Successful in 43m44s
2025-03-30 21:57:36 +02:00
bdf2bb8b31 fix: do not reload items on resume. 2025-03-30 21:57:36 +02:00
ceae91206d Merge pull request 'tests' (#193) from tests into master
All checks were successful
Master / build (push) Successful in 12m5s
Reviewed-on: #193
2025-03-30 19:50:50 +00:00
11c0e744dc ci: Instrumentation tests coverage in ci.
All checks were successful
PR / PR (pull_request) Successful in 1m1s
PR / translations (pull_request) Successful in 1m0s
PR / build (pull_request) Successful in 21m57s
PR test / integrationTests (pull_request) Successful in 50m51s
2025-03-30 20:23:15 +02:00
7374e95b0e ci: Instrumentation tests coverage in ci.
Some checks failed
Check PR code / translations (pull_request) Successful in 1m5s
Check PR code / Lint (pull_request) Successful in 1m8s
Check PR code / build (pull_request) Successful in 18m22s
Check PR code / RunIntegrationTests (pull_request) Has been cancelled
2025-03-27 18:44:44 +01:00
8a7743a6fb ci: Instrumentation tests coverage in ci.
All checks were successful
Check PR code / BuildAndTestAndCoverage (pull_request) Successful in 41m45s
2025-03-26 19:05:48 +01:00
1b2e9edc8c chore: better handling of coroutine dispatchers.
All checks were successful
Check PR code / BuildAndTestAndCoverage (pull_request) Successful in 30m26s
2025-03-25 12:42:43 +01:00
7c65a63315 ci: Instrumentation tests coverage in ci.
All checks were successful
Check PR code / BuildAndTestAndCoverage (pull_request) Successful in 34m46s
2025-03-24 23:09:51 +01:00
02d503e03a chore: comment robolectric tests for now. 2025-03-16 15:44:02 +01:00
24b9320d6d fix: Fixed source deletion test. 2025-03-16 14:27:30 +01:00
ceba58e98f Merge pull request 'Fix alignment changes resetting reader article position' (#190) from davidoskky/ReaderForSelfoss-multiplatform:alignment into master
All checks were successful
Check master code / build (push) Successful in 8m27s
Reviewed-on: #190
2025-03-16 13:14:44 +00:00
c3ee07dd85 Refactor star icon handling
All checks were successful
Check PR code / translations (pull_request) Successful in 1m4s
Check PR code / Lint (pull_request) Successful in 1m25s
Check PR code / build (pull_request) Successful in 11m52s
Extracted all favorite handling to two functions. Makes it a little bit more readable.
2025-03-12 16:07:49 +01:00
93d99192b3 Don't restart activity changing alignment
When changing alignment in the reader we were restarting the reader activity to reload. Doing this led to reloading the article which was initially opened every time you changed alignment. Now, when changing the alignment we retain all existing fragments but command all of them to update their alignment setting.
2025-03-12 16:07:49 +01:00
359dec2ca0 Changelog for v125030711 2025-03-12 11:39:57 +00:00
62354ec70a Merge pull request 'fix: initial status loading issues.' (#192) from connectivity into master
All checks were successful
Check master code / build (push) Successful in 18m28s
Create tag / build (push) Successful in 18m21s
Create tag / createTagAndChangelog (push) Successful in 53s
Create tag / release (push) Successful in 4m19s
Reviewed-on: #192
2025-03-12 11:20:14 +00:00
18a17251ac chore: check changes for translations and android.
All checks were successful
Check PR code / translations (pull_request) Successful in 2m30s
Check PR code / Lint (pull_request) Successful in 2m36s
Check PR code / build (pull_request) Successful in 16m14s
2025-03-11 22:20:07 +01:00
5e91724ee2 fix: initial status loading issues.
Some checks failed
Check PR code / Lint (pull_request) Successful in 3m56s
Check PR code / translations (pull_request) Successful in 1m32s
Check PR code / build (pull_request) Has been cancelled
2025-03-11 22:04:42 +01:00
212d259a33 Merge pull request 'chore: new connectivity dep. Closes #84.' (#189) from connectivity into master
All checks were successful
Check master code / build (push) Successful in 14m19s
Reviewed-on: #189
2025-03-11 13:48:57 +00:00
3bf60f1146 chore: new connectivity dep. Closes #84.
All checks were successful
Check PR code / Lint (pull_request) Successful in 1m2s
Check PR code / translations (pull_request) Successful in 1m24s
Check PR code / build (pull_request) Successful in 14m10s
2025-03-10 21:11:39 +01:00
ef13e300f0 Changelog for v125030681 2025-03-09 17:41:13 +00:00
43 changed files with 1120 additions and 484 deletions

View File

@ -6,42 +6,45 @@ jobs:
BuildAndTestAndCoverage: BuildAndTestAndCoverage:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - uses: actions/checkout@v4
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: "Check android app changes"
id: check-android-changes
uses: tj-actions/changed-files@v45
with:
files: |
androidApp/src/**
shared/src/commonMain/**
shared/src/androidMain/**
shared/src/commonTest/**
- name: Fetch tags - name: Fetch tags
if: steps.check-android-changes.outputs.any_modified == 'true'
run: git fetch --tags -p run: git fetch --tags -p
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
if: steps.check-android-changes.outputs.any_modified == 'true'
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
cache: gradle
- uses: gradle/actions/setup-gradle@v3 - uses: gradle/actions/setup-gradle@v3
if: steps.check-android-changes.outputs.any_modified == 'true'
- uses: android-actions/setup-android@v3 - uses: android-actions/setup-android@v3
if: steps.check-android-changes.outputs.any_modified == 'true'
- name: Configure gradle... - name: Configure gradle...
if: steps.check-android-changes.outputs.any_modified == 'true'
run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true" >> ~/.gradle/gradle.properties run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true" >> ~/.gradle/gradle.properties
- name: Build and test - name: Build and test
run: ./gradlew build -x testReleaseUnitTest -x testDebugUnitTest -x testGithubConfigReleaseUnitTest -x testGithubConfigDebugUnitTest # These tests will be done if: steps.check-android-changes.outputs.any_modified == 'true'
- uses: KengoTODA/actions-setup-docker-compose@v1 run: ./gradlew build -x testReleaseUnitTest -x testDebugUnitTest -x testGithubConfigReleaseUnitTest -x testGithubConfigDebugUnitTest
with:
version: "2.23.3"
# TESTS ARE RUN LOCALLY
# - name: run selfoss
# run: |
# docker compose -f .gitea/workflows/assets/docker-compose.yml up -d
- name: coverage - name: coverage
if: steps.check-android-changes.outputs.any_modified == 'true'
run: | run: |
./gradlew :koverHtmlReport ./gradlew :koverHtmlReport
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: steps.check-android-changes.outputs.any_modified == 'true'
with: with:
name: coverage name: coverage
path: build/reports/kover/html path: build/reports/kover/html
retention-days: 1 retention-days: 1
overwrite: true overwrite: true
include-hidden-files: true include-hidden-files: true
# TESTS ARE RUN LOCALLY
# - name: Clean
# if: always()
# run: |
# docker compose -f .gitea/workflows/assets/docker-compose.yml stop

View File

@ -1,4 +1,4 @@
name: Create tag name: Realease
on: on:
push: push:
branches: branches:
@ -7,7 +7,7 @@ on:
jobs: jobs:
build: build:
uses: ./.gitea/workflows/common_build.yml uses: ./.gitea/workflows/on_called_build.yml
createTagAndChangelog: createTagAndChangelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
@ -86,7 +86,6 @@ jobs:
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
cache: gradle
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Configure gradle... - name: Configure gradle...

View File

@ -1,12 +1,11 @@
name: Check PR code name: PR
on: on:
pull_request: pull_request:
branches: branches:
- master - master
- chore-crowdin-ci
jobs: jobs:
Lint: PR:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
@ -15,7 +14,6 @@ jobs:
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
cache: gradle
- name: Install klint - name: Install klint
run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/ run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/
- name: Install detekt - name: Install detekt
@ -29,7 +27,16 @@ jobs:
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: "Check translations changes"
id: check-translations-changes
uses: tj-actions/changed-files@v45
with:
files: |
androidApp/src/main/res/values/strings.xml
- name: upload translation sources - name: upload translation sources
if: steps.check-translations-changes.outputs.any_modified == 'true'
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
with: with:
config: './.gitea/workflows/assets/crowdin.yml' config: './.gitea/workflows/assets/crowdin.yml'
@ -42,8 +49,10 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: wait - name: wait
if: steps.check-translations-changes.outputs.any_modified == 'true'
run: sleep 10s run: sleep 10s
- name: download translations - name: download translations
if: steps.check-translations-changes.outputs.any_modified == 'true'
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2
with: with:
config: './.gitea/workflows/assets/crowdin.yml' config: './.gitea/workflows/assets/crowdin.yml'
@ -56,17 +65,18 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Check for uncommitted changes - name: Check for uncommitted changes
if: steps.check-translations-changes.outputs.any_modified == 'true'
id: check-changes id: check-changes
uses: mskri/check-uncommitted-changes-action@v1.0.1 uses: mskri/check-uncommitted-changes-action@v1.0.1
- name: Commit Changes - name: Commit Changes
if: steps.check-changes.outputs.changes != '' if: steps.check-translations-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != ''
run: | run: |
git config --global user.email aminecmi+giteadrone@pm.me git config --global user.email aminecmi+giteadrone@pm.me
git config --global user.name giteadrone git config --global user.name giteadrone
git add ./androidApp/src/main/res/* git add ./androidApp/src/main/res/*
git commit -m "translation: translation files" git commit -m "translation: translation files"
- name: Push changes - name: Push changes
if: steps.check-changes.outputs.changes != '' if: steps.check-translations-changes.outputs.any_modified == 'true' && steps.check-changes.outputs.changes != ''
uses: appleboy/git-push-action@v1.0.0 uses: appleboy/git-push-action@v1.0.0
with: with:
author_name: giteadrone author_name: giteadrone
@ -76,4 +86,4 @@ jobs:
branch: ${{ github.head_ref || github.ref_name }} branch: ${{ github.head_ref || github.ref_name }}
build: build:
needs: Lint needs: Lint
uses: ./.gitea/workflows/common_build.yml uses: ./.gitea/workflows/on_called_build.yml

View File

@ -0,0 +1,67 @@
name: PR test
on:
pull_request:
branches:
- master
jobs:
integrationTests:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags -p
- uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: "2.23.3"
- name: run selfoss
run: |
docker compose -f .gitea/workflows/assets/docker-compose.yml up -d
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- uses: gradle/actions/setup-gradle@v3
- uses: android-actions/setup-android@v3
- name: Configure gradle...
run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true" >> ~/.gradle/gradle.properties
- name: Change url until I find a better way to do it
run: |
sed -i "s/const val DEFAULT_URL = \"http:\/\/10\.0\.2\.2\:8888\"/const val DEFAULT_URL = \"http:\/\/172\.17\.0\.1\:8888\"/g" ./androidApp/src/androidTest/kotlin/bou/amine/apps/readerforselfossv2/android/CommonTests.kt
- name: Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
profile: pixel_2
script: |
./gradlew androidApp:clearScreenshotsTask || true
./gradlew androidApp:createScreenshotDirectory
adb logcat -G 16M
./gradlew JacocoDebugCodeCoverage || (./gradlew androidApp:fetchScreenshots && adb logcat 'InputReader:S' 'chatty:S' 'audio_hw_generic:S' 'LogApiCalls:D' '*:I' -d > ./androidApp/build/reports/androidTests/connected/screenshots/logs.txt)
- uses: actions/upload-artifact@v3
with:
name: screenshot-espresso
path: androidApp/build/reports/androidTests/connected/screenshots
retention-days: 2
overwrite: true
include-hidden-files: true
- uses: actions/upload-artifact@v3
with:
path: androidApp/build/reports/androidTests/connected/debug/flavors/githubConfig
retention-days: 1
overwrite: true
include-hidden-files: true
- uses: actions/upload-artifact@v3
with:
name: coverage-espresso
path: androidApp/build/reports/jacoco/JacocoDebugCodeCoverage
retention-days: 1
overwrite: true
include-hidden-files: true
- name: Clean
if: always()
run: |
docker compose -f .gitea/workflows/assets/docker-compose.yml stop

View File

@ -1,4 +1,4 @@
name: Check master code name: Master
on: on:
push: push:
branches: branches:
@ -6,4 +6,4 @@ on:
jobs: jobs:
build: build:
uses: ./.gitea/workflows/common_build.yml uses: ./.gitea/workflows/on_called_build.yml

View File

@ -1,3 +1,49 @@
**v125030901
- Merge pull request 'fix-reload' (#195) from fix-reload into master
- fix: Infinite scroll needs loading stats.
- fix: do not reload items on resume.
- Merge pull request 'tests' (#193) from tests into master
- ci: Instrumentation tests coverage in ci.
- ci: Instrumentation tests coverage in ci.
- ci: Instrumentation tests coverage in ci.
- chore: better handling of coroutine dispatchers.
- ci: Instrumentation tests coverage in ci.
- chore: comment robolectric tests for now.
- fix: Fixed source deletion test.
- Merge pull request 'Fix alignment changes resetting reader article position' (#190) from davidoskky/ReaderForSelfoss-multiplatform:alignment into master
- Refactor star icon handling
- Don't restart activity changing alignment
- Changelog for v125030711
--------------------------------------------------------------------
**v125030711
- Merge pull request 'fix: initial status loading issues.' (#192) from connectivity into master
- chore: check changes for translations and android.
- fix: initial status loading issues.
- Merge pull request 'chore: new connectivity dep. Closes #84.' (#189) from connectivity into master
- chore: new connectivity dep. Closes #84.
- Changelog for v125030681
--------------------------------------------------------------------
**v125030681
- chore: do not send reports on simulators.
- Merge pull request 'chore: do not send reports on simulators.' (#188) from chore-acra-simulator into master
- chore: do not send reports on simulators.
- Merge pull request 'fix: Url validation was not failing login. Added tests.' (#186) from fix-invalid-url into master
- Merge pull request 'chore: crowding ci integration.' (#187) from chore-crowdin-ci into master
- chore: we don't need to check if the url is valid in upsert screen.
- fix: Url validation was not failing login. Added tests.
- chore: crowding ci integration.
- Show a confirmation dialog before deleting sources (#185)
- Changelog for v125020581
--------------------------------------------------------------------
**v125020581 **v125020581
- fix: url can be empty ? - fix: url can be empty ?

View File

@ -10,30 +10,41 @@ plugins {
id("com.mikepenz.aboutlibraries.plugin") id("com.mikepenz.aboutlibraries.plugin")
id("org.jetbrains.kotlinx.kover") id("org.jetbrains.kotlinx.kover")
id("app.cash.sqldelight") version "2.0.2" id("app.cash.sqldelight") version "2.0.2"
jacoco
} }
fun Project.execWithOutput(cmd: String, ignore: Boolean = false): String { fun Project.execWithOutput(
val result: String = ByteArrayOutputStream().use { outputStream -> cmd: String,
project.exec { ignore: Boolean = false,
commandLine = cmd.split(" ") ): String {
standardOutput = outputStream val result: String =
isIgnoreExitValue = ignore ByteArrayOutputStream().use { outputStream ->
project.exec {
commandLine = cmd.split(" ")
standardOutput = outputStream
isIgnoreExitValue = ignore
}
outputStream.toString()
} }
outputStream.toString()
}
return result return result
} }
fun gitVersion(): String { fun gitVersion(): String {
val maybeTagOfCurrentCommit = execWithOutput("git -C ../ describe --contains HEAD", true) val maybeTagOfCurrentCommit = execWithOutput("git -C ../ describe --contains HEAD", true)
val process = if (maybeTagOfCurrentCommit.isEmpty()) { val process =
println("No tag on current commit. Will take the latest one.") if (maybeTagOfCurrentCommit.isEmpty()) {
execWithOutput("git -C ../ for-each-ref refs/tags --sort=-refname --format='%(refname:short)' --count=1") println("No tag on current commit. Will take the latest one.")
} else { execWithOutput("git -C ../ for-each-ref refs/tags --sort=-refname --format='%(refname:short)' --count=1")
println("Tag found on current commit") } else {
execWithOutput("git -C ../ describe --contains HEAD") println("Tag found on current commit")
} execWithOutput("git -C ../ describe --contains HEAD")
return process.replace("^0", "").replace("'", "").substring(1).replace("\\.", "").trim() }
return process
.replace("^0", "")
.replace("'", "")
.substring(1)
.replace("\\.", "")
.trim()
} }
fun versionCodeFromGit(): Int { fun versionCodeFromGit(): Int {
@ -54,6 +65,15 @@ fun versionNameFromGit(): String {
return gitVersion() return gitVersion()
} }
val exclusions =
listOf(
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
)
android { android {
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
@ -85,7 +105,7 @@ android {
// tests // tests
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true" testInstrumentationRunnerArguments["useTestStorageService"] = "true"
} }
packaging { packaging {
resources { resources {
@ -99,6 +119,44 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
} }
getByName("debug") { getByName("debug") {
isTestCoverageEnabled = true
enableAndroidTestCoverage = true
installation {
installOptions("-g", "-r")
}
val androidTests = "connectedAndroidTest"
tasks.register<JacocoReport>("JacocoDebugCodeCoverage") {
// Depend on unit tests and Android tests tasks
dependsOn(listOf(androidTests))
// Set task grouping and description
group = "Reporting"
description = "Execute UI and unit tests, generate and combine Jacoco coverage report"
// Configure reports to generate both XML and HTML formats
reports {
xml.required.set(true)
html.required.set(true)
}
// Set source directories to the main source directory
sourceDirectories.setFrom(layout.projectDirectory.dir("src/main"))
// Set class directories to compiled Java and Kotlin classes, excluding specified exclusions
classDirectories.setFrom(
files(
fileTree(layout.buildDirectory.dir("intermediates/javac/")) {
exclude(exclusions)
},
fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/")) {
exclude(exclusions)
},
),
)
// Collect execution data from .exec and .ec files generated during test execution
executionData.setFrom(
files(
fileTree(layout.buildDirectory) { include(listOf("**/*.exec", "**/*.ec")) },
),
)
}
} }
} }
flavorDimensions.add("build") flavorDimensions.add("build")
@ -111,12 +169,10 @@ android {
namespace = "bou.amine.apps.readerforselfossv2.android" namespace = "bou.amine.apps.readerforselfossv2.android"
testOptions { testOptions {
animationsDisabled = true animationsDisabled = true
execution = "ANDROIDX_TEST_ORCHESTRATOR"
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
} }
} }
dependencies { dependencies {
@ -141,12 +197,12 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.2.0") implementation("androidx.constraintlayout:constraintlayout:2.2.0")
implementation("org.jsoup:jsoup:1.18.3") implementation("org.jsoup:jsoup:1.18.3")
//multidex // multidex
implementation("androidx.multidex:multidex:2.0.1") implementation("androidx.multidex:multidex:2.0.1")
// About // About
implementation("com.mikepenz:aboutlibraries-core:10.5.1") implementation("com.mikepenz:aboutlibraries-core:11.6.3")
implementation("com.mikepenz:aboutlibraries:10.5.1") implementation("com.mikepenz:aboutlibraries:11.6.3")
// Material-ish things // Material-ish things
implementation("com.ashokvarma.android:bottom-navigation-bar:2.2.0") implementation("com.ashokvarma.android:bottom-navigation-bar:2.2.0")
@ -162,43 +218,41 @@ dependencies {
implementation("me.relex:circleindicator:2.1.6") implementation("me.relex:circleindicator:2.1.6")
implementation("androidx.viewpager2:viewpager2:1.1.0") implementation("androidx.viewpager2:viewpager2:1.1.0")
//Dependency Injection // Dependency Injection
implementation("org.kodein.di:kodein-di:7.23.1") implementation("org.kodein.di:kodein-di:7.23.1")
implementation("org.kodein.di:kodein-di-framework-android-x:7.23.1") implementation("org.kodein.di:kodein-di-framework-android-x:7.23.1")
implementation("org.kodein.di:kodein-di-framework-android-x-viewmodel:7.23.1") implementation("org.kodein.di:kodein-di-framework-android-x-viewmodel:7.23.1")
//Settings // Settings
implementation("com.russhwolf:multiplatform-settings-no-arg:1.3.0") implementation("com.russhwolf:multiplatform-settings-no-arg:1.3.0")
//Logging // Logging
implementation("io.github.aakira:napier:2.7.1") implementation("io.github.aakira:napier:2.7.1")
//PhotoView // PhotoView
implementation("com.github.chrisbanes:PhotoView:2.3.0") implementation("com.github.chrisbanes:PhotoView:2.3.0")
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") implementation("androidx.lifecycle:lifecycle-extensions:2.2.0")
// Network information
implementation("com.github.ln-12:multiplatform-connectivity-status:1.3.0")
// SQLDELIGHT // SQLDELIGHT
implementation("app.cash.sqldelight:android-driver:2.0.2") implementation("app.cash.sqldelight:android-driver:2.0.2")
//test // test
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.14") testImplementation("io.mockk:mockk:1.13.14")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test:runner:1.7.0-alpha01")
androidTestImplementation("androidx.test:rules:1.6.1") androidTestImplementation("androidx.test:rules:1.7.0-alpha01")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
implementation("androidx.test.espresso:espresso-idling-resource:3.6.1") implementation("androidx.test.espresso:espresso-idling-resource:3.6.1")
androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1") androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestUtil("androidx.test:orchestrator:1.5.1") androidTestUtil("androidx.test.services:test-services:1.6.0-alpha02")
testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core-ktx:1.6.1") testImplementation("androidx.test:core-ktx:1.7.0-alpha01")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
implementation("ch.acra:acra-http:$acraVersion") implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-toast:$acraVersion") implementation("ch.acra:acra-toast:$acraVersion")
@ -210,16 +264,24 @@ tasks.withType<Test> {
useJUnit() useJUnit()
testLogging { testLogging {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
events = setOf( events =
org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED, setOf(
org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED,
org.gradle.api.tasks.testing.logging.TestLogEvent.STANDARD_ERROR org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED,
) org.gradle.api.tasks.testing.logging.TestLogEvent.STANDARD_ERROR,
)
showStandardStreams = true showStandardStreams = true
} }
if (this.name == "connectedAndroidTest") {
configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
} }
aboutLibraries { aboutLibraries {
excludeFields = arrayOf("generated")
offlineMode = true offlineMode = true
fetchRemoteLicense = false fetchRemoteLicense = false
fetchRemoteFunding = false fetchRemoteFunding = false
@ -227,4 +289,31 @@ aboutLibraries {
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
duplicationRule = com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP duplicationRule = com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
} }
val clearScreenshotsTask =
tasks.register<Exec>("clearScreenshots") {
println("AMINE : clear")
commandLine = listOf("adb", "shell", "rm", "-r", "/storage/emulated/0/Pictures/selfoss_tests/screenshots/*")
}
val createScreenshotDirectoryTask =
tasks.register<Exec>("createScreenshotDirectory") {
println("AMINE : create directory")
group = "reporting"
commandLine = listOf("adb", "shell", "mkdir", "-p", "/storage/emulated/0/Pictures/selfoss_tests/screenshots")
}
tasks.register<Exec>("fetchScreenshots") {
val reportsDirectory = file("$buildDir/reports/androidTests/connected")
println("AMINE : fetch")
group = "reporting"
executable(android.adbExecutable.toString())
commandLine = listOf("adb", "pull", "/storage/emulated/0/Pictures/selfoss_tests/screenshots", reportsDirectory.toString())
finalizedBy(clearScreenshotsTask)
doFirst {
reportsDirectory.mkdirs()
}
}

View File

@ -15,13 +15,17 @@ import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class LoginActivityTest { class `1-LoginActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@ -40,7 +44,7 @@ class LoginActivityTest {
} }
@Test @Test
fun viewIsInitialized() { fun `1-viewIsInitialized`() {
onView(withId(R.id.urlView)).check(matches(isDisplayed())) onView(withId(R.id.urlView)).check(matches(isDisplayed()))
onView(withId(R.id.selfSigned)) onView(withId(R.id.selfSigned))
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
@ -57,28 +61,27 @@ class LoginActivityTest {
} }
@Test @Test
fun urlError() { fun `2-urlError`() {
performLogin("10.0.2.2:8888") performLogin("10.0.2.2:8888")
onView(withId(R.id.urlView)).perform(click()) onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem))) onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem)))
} }
@Test @Test
fun connectError() { fun `3-urlSlashError`() {
performLogin("http://10.0.2.2:8889")
onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.wrong_infos)))
}
@Test
fun urlSlashError() {
performLogin("https://google.fr/toto") performLogin("https://google.fr/toto")
onView(withId(R.id.urlView)).perform(click()) onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem))) onView(withId(R.id.urlView)).check(matches(withError(R.string.login_url_problem)))
} }
@Test @Test
fun multiError() { fun `4-connectError`() {
performLogin("http://10.0.2.2:8889")
onView(withId(R.id.urlView)).check(matches(withError(R.string.wrong_infos)))
}
@Test
fun `5-multiError`() {
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
@ -86,8 +89,10 @@ class LoginActivityTest {
} }
@Test @Test
fun connect() { fun `6-connect`() {
performLogin() performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed())) onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
onView(withText("OK")).perform(click())
checkHomeLoadingDone()
} }
} }

View File

@ -1,6 +1,7 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
@ -14,21 +15,29 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class HomeActivityTest { class `2-HomeActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@Before @Before
fun init() { fun registerIdlingResource() {
loginAndInitHome() IdlingRegistry
.getInstance()
.register(CountingIdlingResourceSingleton.countingIdlingResource)
checkHomeLoadingDone()
} }
@Test @Test

View File

@ -2,14 +2,17 @@ package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.isSelected import androidx.test.espresso.matcher.ViewMatchers.isSelected
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.Before import org.junit.Before
@ -19,9 +22,11 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityTest { @Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
class `3-SettingsActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
lateinit var context: Context lateinit var context: Context
@Before @Before
@ -29,7 +34,9 @@ class SettingsActivityTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome() IdlingRegistry
.getInstance()
.register(CountingIdlingResourceSingleton.countingIdlingResource)
openMenu() openMenu()
onView(withText(R.string.title_activity_settings)).perform(click()) onView(withText(R.string.title_activity_settings)).perform(click())
} }
@ -68,6 +75,9 @@ class SettingsActivityTest {
changeAndSaveSetting("", "10") { changeAndSaveSetting("", "10") {
onView(withText(R.string.pref_api_timeout)).perform(click()) onView(withText(R.string.pref_api_timeout)).perform(click())
} }
changeAndSaveSetting("", "60") {
onView(withText(R.string.pref_api_timeout)).perform(click())
}
} }
@Test @Test
@ -87,6 +97,7 @@ class SettingsActivityTest {
@Test @Test
fun testAbout() { fun testAbout() {
onView(withText(R.string.action_about)).perform(click()) onView(withText(R.string.action_about)).perform(click())
onView(isRoot()).perform(waitUntilShown("ACRA", 30000))
onView(withText("ACRA")).check(matches(isDisplayed())) onView(withText("ACRA")).check(matches(isDisplayed()))
} }
} }

View File

@ -3,6 +3,7 @@ package bou.amine.apps.readerforselfossv2.android
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.action.ViewActions.replaceText
@ -19,22 +20,29 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@LargeTest @LargeTest
class SettingsActivityGeneralTest { class `4-SettingsActivityGeneralTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@Before @Before
fun init() { fun init() {
loginAndInitHome() IdlingRegistry
.getInstance()
.register(CountingIdlingResourceSingleton.countingIdlingResource)
openActionBarOverflowOrOptionsMenu( openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext(), ApplicationProvider.getApplicationContext(),
) )

View File

@ -1,29 +1,34 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityReaderTest { class `5-SettingsActivityReaderTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(SettingsActivity::class.java)
lateinit var context: Context lateinit var context: Context
@ -32,14 +37,17 @@ class SettingsActivityReaderTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome() IdlingRegistry
openActionBarOverflowOrOptionsMenu( .getInstance()
ApplicationProvider.getApplicationContext(), .register(CountingIdlingResourceSingleton.countingIdlingResource)
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_viewer)).perform(click()) onView(withText(R.string.pref_header_viewer)).perform(click())
} }
@After
fun back() {
onView(isRoot()).perform(ViewActions.pressBack())
}
@Test @Test
fun testReader() { fun testReader() {
onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).check( onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).check(

View File

@ -1,31 +1,36 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SettingsActivityOfflineTest { class `6-SettingsActivityOfflineTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(SettingsActivity::class.java)
lateinit var context: Context lateinit var context: Context
@ -34,14 +39,17 @@ class SettingsActivityOfflineTest {
activityRule.scenario.onActivity { activity -> activityRule.scenario.onActivity { activity ->
context = activity.window.context context = activity.window.context
} }
loginAndInitHome() IdlingRegistry
openActionBarOverflowOrOptionsMenu( .getInstance()
ApplicationProvider.getApplicationContext(), .register(CountingIdlingResourceSingleton.countingIdlingResource)
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_offline)).perform(click()) onView(withText(R.string.pref_header_offline)).perform(click())
} }
@After
fun back() {
onView(isRoot()).perform(ViewActions.pressBack())
}
@Suppress("detekt:LongMethod") @Suppress("detekt:LongMethod")
@Test @Test
fun testOffline() { fun testOffline() {

View File

@ -2,6 +2,7 @@ package bou.amine.apps.readerforselfossv2.android
import androidx.test.espresso.AmbiguousViewMatcherException import androidx.test.espresso.AmbiguousViewMatcherException
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.swipeDown import androidx.test.espresso.action.ViewActions.swipeDown
@ -14,6 +15,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -21,19 +23,22 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.UUID import java.util.UUID
@Suppress("ktlint:standard:class-naming", "detekt:ClassNaming")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class SourcesActivityTest { class `7-SourcesActivityTest` : WithANRException() {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java) val activityRule = ActivityScenarioRule(HomeActivity::class.java)
lateinit var sourceName: String lateinit var sourceName: String
@Before @Before
fun init() { fun init() {
IdlingRegistry
.getInstance()
.register(CountingIdlingResourceSingleton.countingIdlingResource)
sourceName = UUID.randomUUID().toString().substring(0, 15) sourceName = UUID.randomUUID().toString().substring(0, 15)
loginAndInitHome()
goToSources() goToSources()
} }
@ -71,12 +76,8 @@ class SourcesActivityTest {
fun deleteTheCreatedSource() { fun deleteTheCreatedSource() {
onView(withText(sourceName)).check(matches(isDisplayed())) onView(withText(sourceName)).check(matches(isDisplayed()))
onView(withId(R.id.deleteBtn)).perform(click()) onView(withId(R.id.deleteBtn)).perform(click())
onView(withText(R.string.confirm_delete_title)).check(matches(isDisplayed()))
onView(withId(android.R.id.button1)).perform(click())
onView(withText(sourceName)).check(doesNotExist()) onView(withText(sourceName)).check(doesNotExist())
} }
private fun goToSources() {
openMenu()
onView(withText(R.string.menu_home_sources))
.perform(click())
}
} }

View File

@ -1,7 +1,12 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Environment.DIRECTORY_PICTURES
import android.os.Environment.getExternalStoragePublicDirectory
import android.util.Log
import androidx.annotation.ArrayRes import androidx.annotation.ArrayRes
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.click
@ -9,29 +14,39 @@ import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.base.DefaultFailureHandler
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
import org.hamcrest.Matchers.hasToString import org.hamcrest.Matchers.hasToString
import org.junit.BeforeClass
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
// For now, do not move this as it is modified by the integration tests
const val DEFAULT_URL = "http://10.0.2.2:8888"
fun performLogin(someUrl: String? = null) { fun performLogin(someUrl: String? = null) {
Log.i("AUTOMATION", "The url used will be ${if (!someUrl.isNullOrEmpty()) someUrl else DEFAULT_URL}")
onView(withId(R.id.urlView)).perform(click()).perform( onView(withId(R.id.urlView)).perform(click()).perform(
typeTextIntoFocusedView( typeTextIntoFocusedView(
if (!someUrl.isNullOrEmpty()) someUrl else "http://10.0.2.2:8888", if (!someUrl.isNullOrEmpty()) someUrl else DEFAULT_URL,
), ),
) )
onView(withId(R.id.signInButton)).perform(click()) onView(withId(R.id.signInButton)).perform(click())
} }
fun loginAndInitHome() {
performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
onView(withText("OK")).perform(click())
}
fun changeAndCancelSetting( fun changeAndCancelSetting(
oldValue: String, oldValue: String,
newValue: String, newValue: String,
@ -97,6 +112,12 @@ fun testPreferencesFromArray(
} }
} }
fun goToSources() {
openMenu()
onView(withText(R.string.menu_home_sources))
.perform(click())
}
fun testAddSourceWithUrl( fun testAddSourceWithUrl(
url: String, url: String,
sourceName: String, sourceName: String,
@ -119,3 +140,93 @@ fun testAddSourceWithUrl(
.perform(click()) .perform(click())
onView(withText(sourceName)).check(matches(isDisplayed())) onView(withText(sourceName)).check(matches(isDisplayed()))
} }
fun checkHomeLoadingDone() {
onView(withId(R.id.swipeRefreshLayout)).inRoot(not(isDialog())).perform(waitForRecyclerViewToStopLoading(300000))
}
@Suppress("detekt:UtilityClassWithPublicConstructor")
open class WithANRException {
companion object {
// Running count of the number of Android Not Responding dialogues to prevent endless dismissal.
private var anrCount = 0
// `RootViewWithoutFocusException` class is private, need to match the message (instead of using type matching).
private val rootViewWithoutFocusExceptionMsg =
java.lang.String.format(
Locale.ROOT,
"Waited for the root of the view hierarchy to have " +
"window focus and not request layout for 10 seconds. If you specified a non " +
"default root matcher, it may be picking a root that never takes focus. " +
"Root:",
)
private const val OTHER_EXCEPTION = "System Ul isn't responding"
private fun handleAnrDialogue() {
val device = UiDevice.getInstance(getInstrumentation())
// If running the device in English Locale
val waitButton = device.findObject(UiSelector().textContains("wait"))
if (waitButton.exists()) waitButton.click()
}
@JvmStatic
@BeforeClass
fun setUpHandler() {
Espresso.setFailureHandler { error, viewMatcher ->
takeScreenshot()
if (error.message!!.contains(OTHER_EXCEPTION)) {
handleAnrDialogue()
} else if (error.message!!.contains(rootViewWithoutFocusExceptionMsg) &&
anrCount < 20
) {
anrCount++
handleAnrDialogue()
} else { // chain all failures down to the default espresso handler
Log.e("AMINE", "AMINE : ${error.message}")
println("AMINE : ${error.message}")
DefaultFailureHandler(getInstrumentation().targetContext).handle(error, viewMatcher)
}
}
}
}
}
@Suppress("detekt:NestedBlockDepth")
fun takeScreenshot() {
try {
val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
val folder =
File(
File(
getExternalStoragePublicDirectory(DIRECTORY_PICTURES),
"selfoss_tests",
).absolutePath,
"screenshots",
)
if (!folder.exists()) {
folder.mkdirs()
}
var out: BufferedOutputStream? = null
val size = folder.list().size + 1
try {
out = BufferedOutputStream(FileOutputStream(folder.path + "/" + size + ".png"))
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
Log.d("Screenshots", "Screenshot taken")
} catch (e: IOException) {
Log.e("Screenshots", "Could not save the screenshot", e)
} finally {
if (out != null) {
try {
out.close()
} catch (e: IOException) {
Log.e("Screenshots", "Could not save the screenshot", e)
}
}
}
} catch (ex: IOException) {
Log.e("Screenshots", "Could not take the screenshot", ex)
}
}

View File

@ -8,22 +8,32 @@ import android.widget.RelativeLayout
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isVisible
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.PerformException
import androidx.test.espresso.Root import androidx.test.espresso.Root
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup
import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.hasSibling
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withChild import androidx.test.espresso.matcher.ViewMatchers.withChild
import androidx.test.espresso.matcher.ViewMatchers.withClassName import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withResourceName import androidx.test.espresso.matcher.ViewMatchers.withResourceName
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.any
import org.hamcrest.Description import org.hamcrest.Description
import org.hamcrest.Matcher import org.hamcrest.Matcher
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher import org.hamcrest.TypeSafeMatcher
import java.util.concurrent.TimeoutException
fun withError( fun withError(
@StringRes id: Int, @StringRes id: Int,
@ -44,6 +54,86 @@ fun withError(
} }
} }
fun waitUntilShown(
viewText: String,
millis: Long,
): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isRoot()
override fun getDescription(): String = "wait for $millis millis, for a specific view with text <$viewText> to be visible."
override fun perform(
uiController: UiController,
view: View,
) {
uiController.loopMainThreadUntilIdle()
val startTime = System.currentTimeMillis()
val endTime = startTime + millis
val viewMatcher = withText(viewText)
do {
for (child in TreeIterables.breadthFirstViewTraversal(view)) {
if (viewMatcher.matches(child) && child.isShown) {
return
}
}
uiController.loopMainThreadForAtLeast(100)
} while (System.currentTimeMillis() < endTime)
// timeout happens
throw PerformException
.Builder()
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(view))
.withCause(TimeoutException())
.build()
}
}
}
fun waitForRecyclerViewToStopLoading(millis: Long): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = any(View::class.java)
override fun getDescription(): String = "wait for $millis millis for the recyclerview to stop loading."
override fun perform(
uiController: UiController,
view: View?,
) {
uiController.loopMainThreadUntilIdle()
val startTime = System.currentTimeMillis()
val endTime = startTime + millis
do {
// either the empty view is displayed
for (child in TreeIterables.breadthFirstViewTraversal(view)) {
// found view with required ID
if (withId(R.id.emptyText).matches(child) && child.isVisible) {
return
}
}
// or the refresh layout is refreshing
if (view is SwipeRefreshLayout && !view.isRefreshing) {
return
}
uiController.loopMainThreadForAtLeast(100)
} while (System.currentTimeMillis() < endTime)
// timeout happens
throw PerformException
.Builder()
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(view))
.withCause(TimeoutException())
.build()
}
}
}
fun isPopupWindow(): Matcher<Root> = isPlatformPopup() fun isPopupWindow(): Matcher<Root> = isPlatformPopup()
fun withDrawable( fun withDrawable(

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MyApp"
android:allowBackup="false"
android:configChanges="uiMode"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/NoBar"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".LoginActivity"
android:label="@string/title_activity_login"></activity>
<activity android:name=".HomeActivity"></activity>
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings"
android:parentActivityName=".HomeActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".HomeActivity" />
</activity>
<activity
android:name=".SourcesActivity"
android:parentActivityName=".HomeActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".HomeActivity" />
</activity>
<activity
android:name=".UpsertSourceActivity"
android:exported="true"
android:parentActivityName=".SourcesActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".SourcesActivity" />
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity android:name=".ReaderActivity"></activity>
<activity
android:name=".ImageActivity"
android:theme="@style/Theme.AppCompat.ImageActivity"></activity>
<meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="true" />
<meta-data
android:name="android.max_aspect"
android:value="2.1" />
</application>
</manifest>

View File

@ -104,7 +104,7 @@ class HomeActivity :
if (appSettingsService.isItemCachingEnabled()) { if (appSettingsService.isItemCachingEnabled()) {
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
repository.tryToCacheItemsAndGetNewOnes() repository.tryToCacheItemsAndGetNewOnes()
CountingIdlingResourceSingleton.decrement() CountingIdlingResourceSingleton.decrement()
} }
@ -120,12 +120,9 @@ class HomeActivity :
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
repository.offlineOverride = false repository.offlineOverride = false
lastFetchDone = false lastFetchDone = false
CountingIdlingResourceSingleton.increment() items.clear()
CoroutineScope(Dispatchers.Main).launch { getElementsAccordingToTab()
getElementsAccordingToTab() binding.swipeRefreshLayout.isRefreshing = false
binding.swipeRefreshLayout.isRefreshing = false
CountingIdlingResourceSingleton.decrement()
}
} }
val swipeDirs = val swipeDirs =
@ -289,7 +286,7 @@ class HomeActivity :
handleRecurringTask() handleRecurringTask()
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
repository.handleDBActions() repository.handleDBActions()
CountingIdlingResourceSingleton.decrement() CountingIdlingResourceSingleton.decrement()
} }
@ -462,19 +459,28 @@ class HomeActivity :
appendResults: Boolean, appendResults: Boolean,
itemType: ItemType, itemType: ItemType,
) { ) {
CountingIdlingResourceSingleton.increment() @Suppress("detekt:ComplexCondition")
CoroutineScope(Dispatchers.Main).launch { if ((appendResults && items.size > 0) || (!appendResults && items.size == 0)) {
CountingIdlingResourceSingleton.increment()
binding.swipeRefreshLayout.isRefreshing = true binding.swipeRefreshLayout.isRefreshing = true
repository.displayedItems = itemType CoroutineScope(Dispatchers.IO).launch {
items = repository.displayedItems = itemType
if (appendResults) { items =
repository.getOlderItems() if (appendResults) {
} else { repository.getOlderItems()
repository.getNewerItems() } else {
repository.getNewerItems()
}
CountingIdlingResourceSingleton.increment()
launch(Dispatchers.Main) {
binding.swipeRefreshLayout.isRefreshing = false
handleListResult()
CountingIdlingResourceSingleton.decrement()
} }
binding.swipeRefreshLayout.isRefreshing = false CountingIdlingResourceSingleton.decrement()
}
} else {
handleListResult() handleListResult()
CountingIdlingResourceSingleton.decrement()
} }
} }
@ -534,7 +540,10 @@ class HomeActivity :
} }
private fun reloadBadges() { private fun reloadBadges() {
if (appSettingsService.isDisplayUnreadCountEnabled() || appSettingsService.isDisplayAllCountEnabled()) { if (appSettingsService.isInfiniteLoadingEnabled() ||
appSettingsService.isDisplayUnreadCountEnabled() ||
appSettingsService.isDisplayAllCountEnabled()
) {
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repository.reloadBadges() repository.reloadBadges()
@ -613,22 +622,26 @@ class HomeActivity :
needsConfirmation(R.string.menu_home_refresh, R.string.refresh_dialog_message) { needsConfirmation(R.string.menu_home_refresh, R.string.refresh_dialog_message) {
Toast.makeText(this, R.string.refresh_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.refresh_in_progress, Toast.LENGTH_SHORT).show()
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
val updatedRemote = repository.updateRemote() val updatedRemote = repository.updateRemote()
if (updatedRemote) { CountingIdlingResourceSingleton.increment()
Toast launch(Dispatchers.Main) {
.makeText( if (updatedRemote) {
this@HomeActivity, Toast
R.string.refresh_success_response, .makeText(
Toast.LENGTH_LONG, this@HomeActivity,
).show() R.string.refresh_success_response,
} else { Toast.LENGTH_LONG,
Toast ).show()
.makeText( } else {
this@HomeActivity, Toast
R.string.refresh_failer_message, .makeText(
Toast.LENGTH_SHORT, this@HomeActivity,
).show() R.string.refresh_failer_message,
Toast.LENGTH_SHORT,
).show()
}
CountingIdlingResourceSingleton.decrement()
} }
CountingIdlingResourceSingleton.decrement() CountingIdlingResourceSingleton.decrement()
} }
@ -639,30 +652,33 @@ class HomeActivity :
R.id.readAll -> { R.id.readAll -> {
if (elementsShown == ItemType.UNREAD) { if (elementsShown == ItemType.UNREAD) {
needsConfirmation(R.string.readAll, R.string.markall_dialog_message) { needsConfirmation(R.string.readAll, R.string.markall_dialog_message) {
binding.swipeRefreshLayout.isRefreshing = true
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { binding.swipeRefreshLayout.isRefreshing = true
CoroutineScope(Dispatchers.IO).launch {
val success = repository.markAllAsRead(items) val success = repository.markAllAsRead(items)
if (success) { CountingIdlingResourceSingleton.increment()
Toast launch(Dispatchers.Main) {
.makeText( if (success) {
this@HomeActivity, Toast
R.string.all_posts_read, .makeText(
Toast.LENGTH_SHORT, this@HomeActivity,
).show() R.string.all_posts_read,
tabNewBadge.removeBadge() Toast.LENGTH_SHORT,
).show()
tabNewBadge.removeBadge()
getElementsAccordingToTab() getElementsAccordingToTab()
} else { } else {
Toast Toast
.makeText( .makeText(
this@HomeActivity, this@HomeActivity,
R.string.all_posts_not_read, R.string.all_posts_not_read,
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
}
binding.swipeRefreshLayout.isRefreshing = false
CountingIdlingResourceSingleton.decrement()
} }
handleListResult()
binding.swipeRefreshLayout.isRefreshing = false
CountingIdlingResourceSingleton.decrement() CountingIdlingResourceSingleton.decrement()
} }
} }

View File

@ -108,7 +108,7 @@ class LoginActivity :
private fun goToMain() { private fun goToMain() {
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
repository.updateApiInformation() repository.updateApiInformation()
ACRA.errorReporter.putCustomData( ACRA.errorReporter.putCustomData(
"SELFOSS_API_VERSION", "SELFOSS_API_VERSION",
@ -127,8 +127,12 @@ class LoginActivity :
binding.urlView.error = getString(R.string.wrong_infos) binding.urlView.error = getString(R.string.wrong_infos)
binding.loginView.error = getString(R.string.wrong_infos) binding.loginView.error = getString(R.string.wrong_infos)
binding.passwordView.error = getString(R.string.wrong_infos) binding.passwordView.error = getString(R.string.wrong_infos)
binding.urlView.requestFocus()
showProgress(false)
} }
@Suppress("detekt:LongMethod")
private fun attemptLogin() { private fun attemptLogin() {
// Reset errors. // Reset errors.
binding.urlView.error = null binding.urlView.error = null
@ -160,34 +164,41 @@ class LoginActivity :
repository.refreshLoginInformation(url, login, password) repository.refreshLoginInformation(url, login, password)
CountingIdlingResourceSingleton.increment() CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
repository.updateApiInformation() repository.updateApiInformation()
val result = repository.login()
CountingIdlingResourceSingleton.increment()
launch(Dispatchers.Main) {
if (result) {
val errorFetching = repository.checkIfFetchFails()
if (!errorFetching) {
goToMain()
} else {
preferenceError()
}
} else {
preferenceError()
}
CountingIdlingResourceSingleton.decrement()
}
} catch (e: Exception) { } catch (e: Exception) {
if (e.message?.startsWith("No transformation found") == true) { CountingIdlingResourceSingleton.increment()
Toast launch(Dispatchers.Main) {
.makeText( if (e.message?.startsWith("No transformation found") == true) {
applicationContext, Toast
R.string.application_selfoss_only, .makeText(
Toast.LENGTH_LONG, applicationContext,
).show() R.string.application_selfoss_only,
preferenceError() Toast.LENGTH_LONG,
showProgress(false) ).show()
preferenceError()
}
CountingIdlingResourceSingleton.decrement()
} }
} finally {
CountingIdlingResourceSingleton.decrement()
} }
val result = repository.login()
if (result) {
val errorFetching = repository.checkIfFetchFails()
if (!errorFetching) {
goToMain()
} else {
preferenceError()
}
} else {
preferenceError()
}
showProgress(false)
CountingIdlingResourceSingleton.decrement()
} }
} }
@ -300,6 +311,7 @@ class LoginActivity :
.withAboutSpecial2Description(AppSettingsService.BUG_URL) .withAboutSpecial2Description(AppSettingsService.BUG_URL)
.withAboutSpecial1("Project Page") .withAboutSpecial1("Project Page")
.withAboutSpecial1Description(AppSettingsService.SOURCE_URL) .withAboutSpecial1Description(AppSettingsService.SOURCE_URL)
.withShowLoadingProgress(false)
.start(this) .start(this)
true true
} }

View File

@ -10,18 +10,16 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.multidex.MultiDexApplication import androidx.multidex.MultiDexApplication
import bou.amine.apps.readerforselfossv2.android.testing.TestingHelper import bou.amine.apps.readerforselfossv2.android.testing.TestingHelper
import bou.amine.apps.readerforselfossv2.android.viewmodel.AppViewModel
import bou.amine.apps.readerforselfossv2.dao.DriverFactory import bou.amine.apps.readerforselfossv2.dao.DriverFactory
import bou.amine.apps.readerforselfossv2.dao.ReaderForSelfossDB import bou.amine.apps.readerforselfossv2.dao.ReaderForSelfossDB
import bou.amine.apps.readerforselfossv2.di.networkModule import bou.amine.apps.readerforselfossv2.di.networkModule
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
import com.github.ln_12.library.ConnectivityStatus import bou.amine.apps.readerforselfossv2.service.ConnectivityService
import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ACRA import org.acra.ACRA
import org.acra.ReportField import org.acra.ReportField
@ -44,27 +42,21 @@ class MyApp :
import(networkModule) import(networkModule)
bind<DriverFactory>() with singleton { DriverFactory(applicationContext) } bind<DriverFactory>() with singleton { DriverFactory(applicationContext) }
bind<ReaderForSelfossDB>() with singleton { ReaderForSelfossDB(driverFactory.createDriver()) } bind<ReaderForSelfossDB>() with singleton { ReaderForSelfossDB(driverFactory.createDriver()) }
bind<ConnectivityService>() with singleton { ConnectivityService() }
bind<Repository>() with bind<Repository>() with
singleton { singleton {
Repository( Repository(
instance(), instance(),
instance(), instance(),
isConnectionAvailable, instance(),
instance(), instance(),
) )
} }
bind<ConnectivityStatus>() with singleton { ConnectivityStatus(applicationContext) }
bind<AppViewModel>() with singleton { AppViewModel(repository = instance()) }
} }
private val repository: Repository by instance() private val repository: Repository by instance()
private val viewModel: AppViewModel by instance()
private val connectivityStatus: ConnectivityStatus by instance()
private val driverFactory: DriverFactory by instance() private val driverFactory: DriverFactory by instance()
private val connectivityService: ConnectivityService by instance()
@Suppress("detekt:ForbiddenComment")
// TODO: handle with the "previous" way
private val isConnectionAvailable: MutableStateFlow<Boolean> = MutableStateFlow(true)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -77,13 +69,12 @@ class MyApp :
ProcessLifecycleOwner.get().lifecycle.addObserver( ProcessLifecycleOwner.get().lifecycle.addObserver(
AppLifeCycleObserver( AppLifeCycleObserver(
connectivityStatus, connectivityService,
repository,
), ),
) )
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Default).launch {
viewModel.networkAvailableProvider.collect { networkAvailable -> connectivityService.networkAvailableProvider.collect { networkAvailable ->
val toastMessage = val toastMessage =
if (networkAvailable) { if (networkAvailable) {
repository.handleDBActions() repository.handleDBActions()
@ -189,18 +180,15 @@ class MyApp :
} }
class AppLifeCycleObserver( class AppLifeCycleObserver(
val connectivityStatus: ConnectivityStatus, val connectivityService: ConnectivityService,
val repository: Repository,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
super.onResume(owner) super.onResume(owner)
repository.connectionMonitored = true connectivityService.start()
connectivityStatus.start()
} }
override fun onPause(owner: LifecycleOwner) { override fun onPause(owner: LifecycleOwner) {
repository.connectionMonitored = false connectivityService.stop()
connectivityStatus.stop()
super.onPause(owner) super.onPause(owner)
} }
} }

View File

@ -27,7 +27,7 @@ class ReaderActivity :
DIAware { DIAware {
private var currentItem: Int = 0 private var currentItem: Int = 0
private lateinit var toolbarMenu: Menu private var toolbarMenu: Menu? = null
private lateinit var binding: ActivityReaderBinding private lateinit var binding: ActivityReaderBinding
@ -37,22 +37,6 @@ class ReaderActivity :
private val repository: Repository by instance() private val repository: Repository by instance()
private val appSettingsService: AppSettingsService by instance() private val appSettingsService: AppSettingsService by instance()
private fun showMenuItem(willAddToFavorite: Boolean) {
if (willAddToFavorite) {
toolbarMenu.findItem(R.id.star).icon?.setTint(Color.WHITE)
} else {
toolbarMenu.findItem(R.id.star).icon?.setTint(Color.RED)
}
}
private fun canFavorite() {
showMenuItem(true)
}
private fun canRemoveFromFavorite() {
showMenuItem(false)
}
@Suppress("detekt:SwallowedException") @Suppress("detekt:SwallowedException")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -73,14 +57,21 @@ class ReaderActivity :
finish() finish()
} }
try { readItem()
readItem(allItems[currentItem])
} catch (e: IndexOutOfBoundsException) {
finish()
}
binding.pager.adapter = ScreenSlidePagerAdapter(this) binding.pager.adapter = ScreenSlidePagerAdapter(this)
binding.pager.setCurrentItem(currentItem, false) binding.pager.setCurrentItem(currentItem, false)
binding.pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
currentItem = position
updateStarIcon()
readItem()
}
},
)
} }
override fun onResume() { override fun onResume() {
@ -89,14 +80,22 @@ class ReaderActivity :
binding.indicator.setViewPager(binding.pager) binding.indicator.setViewPager(binding.pager)
} }
private fun readItem(item: SelfossModel.Item) { private fun readItem() {
if (appSettingsService.isMarkOnScrollEnabled() && !appSettingsService.getPublicAccess()) { val item = allItems.getOrNull(currentItem)
if (appSettingsService.isMarkOnScrollEnabled() && !appSettingsService.getPublicAccess() && item != null) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repository.markAsRead(item) repository.markAsRead(item)
} }
} }
} }
private fun updateStarIcon() {
if (toolbarMenu != null) {
val isStarred = allItems.getOrNull(currentItem)?.starred ?: false
toolbarMenu!!.findItem(R.id.star)?.icon?.setTint(if (isStarred) Color.RED else Color.WHITE)
}
}
override fun onSaveInstanceState(oldInstanceState: Bundle) { override fun onSaveInstanceState(oldInstanceState: Bundle) {
super.onSaveInstanceState(oldInstanceState) super.onSaveInstanceState(oldInstanceState)
oldInstanceState.clear() oldInstanceState.clear()
@ -136,13 +135,14 @@ class ReaderActivity :
private fun alignmentMenu() { private fun alignmentMenu() {
val showJustify = appSettingsService.getActiveAllignment() == AppSettingsService.ALIGN_LEFT val showJustify = appSettingsService.getActiveAllignment() == AppSettingsService.ALIGN_LEFT
toolbarMenu.findItem(R.id.align_left).isVisible = !showJustify if (toolbarMenu != null) {
toolbarMenu.findItem(R.id.align_justify).isVisible = showJustify toolbarMenu!!.findItem(R.id.align_left).isVisible = !showJustify
toolbarMenu!!.findItem(R.id.align_justify).isVisible = showJustify
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater = menuInflater menuInflater.inflate(R.menu.reader_menu, menu)
inflater.inflate(R.menu.reader_menu, menu)
toolbarMenu = menu toolbarMenu = menu
alignmentMenu() alignmentMenu()
@ -150,87 +150,50 @@ class ReaderActivity :
if (appSettingsService.getPublicAccess()) { if (appSettingsService.getPublicAccess()) {
menu.removeItem(R.id.star) menu.removeItem(R.id.star)
} else { } else {
if (allItems.isNotEmpty() && allItems[currentItem].starred) { updateStarIcon()
canRemoveFromFavorite()
} else {
canFavorite()
}
binding.pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
if (!allItems.isNullOrEmpty() && allItems.size >= position) {
if (allItems[position].starred) {
canRemoveFromFavorite()
} else {
canFavorite()
}
readItem(allItems[position])
}
}
},
)
} }
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
fun afterSave() {
allItems[binding.pager.currentItem] =
allItems[binding.pager.currentItem].toggleStar()
canRemoveFromFavorite()
}
fun afterUnsave() {
allItems[binding.pager.currentItem] = allItems[binding.pager.currentItem].toggleStar()
canFavorite()
}
when (item.itemId) { when (item.itemId) {
android.R.id.home -> { android.R.id.home -> onBackPressedDispatcher.onBackPressed()
onBackPressedDispatcher.onBackPressed() R.id.star -> toggleFavorite()
return true R.id.align_left -> switchAlignmentSetting(AppSettingsService.ALIGN_LEFT)
} R.id.align_justify -> switchAlignmentSetting(AppSettingsService.JUSTIFY)
R.id.star -> {
if (allItems[binding.pager.currentItem].starred) {
CoroutineScope(Dispatchers.IO).launch {
repository.unstarr(allItems[binding.pager.currentItem])
}
afterUnsave()
} else {
CoroutineScope(Dispatchers.IO).launch {
repository.starr(allItems[binding.pager.currentItem])
}
afterSave()
}
}
R.id.align_left -> {
switchAlignmentSetting(AppSettingsService.ALIGN_LEFT)
refreshFragment()
}
R.id.align_justify -> {
switchAlignmentSetting(AppSettingsService.JUSTIFY)
refreshFragment()
}
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
private fun switchAlignmentSetting(allignment: Int) { private fun toggleFavorite() {
appSettingsService.changeAllignment(allignment) val item = allItems.getOrNull(currentItem) ?: return
alignmentMenu()
val starred = item.starred
CoroutineScope(Dispatchers.IO).launch {
if (starred) {
repository.unstarr(item)
} else {
repository.starr(item)
}
}
item.toggleStar()
updateStarIcon()
} }
private fun refreshFragment() { private fun switchAlignmentSetting(alignment: Int) {
finish() appSettingsService.changeAllignment(alignment)
overridePendingTransition(0, 0) alignmentMenu()
startActivity(intent)
overridePendingTransition(0, 0) val fragmentManager = supportFragmentManager
val fragments = fragmentManager.fragments
for (fragment in fragments) {
if (fragment is ArticleFragment) {
fragment.refreshAlignment()
}
}
} }
} }

View File

@ -50,6 +50,7 @@ class SourcesActivity :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
CountingIdlingResourceSingleton.increment()
val mLayoutManager = LinearLayoutManager(this) val mLayoutManager = LinearLayoutManager(this)
var items: ArrayList<SelfossModel.SourceDetail> var items: ArrayList<SelfossModel.SourceDetail>
@ -57,25 +58,28 @@ class SourcesActivity :
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.layoutManager = mLayoutManager
CountingIdlingResourceSingleton.increment() CoroutineScope(Dispatchers.IO).launch {
CoroutineScope(Dispatchers.Main).launch {
val response = repository.getSourcesDetails() val response = repository.getSourcesDetails()
if (response.isNotEmpty()) { CountingIdlingResourceSingleton.increment()
items = response launch(Dispatchers.Main) {
val mAdapter = if (response.isNotEmpty()) {
SourcesListAdapter( items = response
this@SourcesActivity, val mAdapter =
items, SourcesListAdapter(
) this@SourcesActivity,
binding.recyclerView.adapter = mAdapter items,
mAdapter.notifyDataSetChanged() )
} else { binding.recyclerView.adapter = mAdapter
Toast mAdapter.notifyDataSetChanged()
.makeText( } else {
this@SourcesActivity, Toast
R.string.cant_get_sources, .makeText(
Toast.LENGTH_SHORT, this@SourcesActivity,
).show() R.string.cant_get_sources,
Toast.LENGTH_SHORT,
).show()
}
CountingIdlingResourceSingleton.decrement()
} }
CountingIdlingResourceSingleton.decrement() CountingIdlingResourceSingleton.decrement()
} }

View File

@ -9,6 +9,7 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityUpsertSourceBinding import bou.amine.apps.readerforselfossv2.android.databinding.ActivityUpsertSourceBinding
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import bou.amine.apps.readerforselfossv2.model.NetworkUnavailableException import bou.amine.apps.readerforselfossv2.model.NetworkUnavailableException
import bou.amine.apps.readerforselfossv2.model.SelfossModel import bou.amine.apps.readerforselfossv2.model.SelfossModel
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
@ -108,36 +109,42 @@ class UpsertSourceActivity :
binding.progress.visibility = View.GONE binding.progress.visibility = View.GONE
} }
CoroutineScope(Dispatchers.Main).launch { CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.IO).launch {
try { try {
val items = repository.getSpouts() val items = repository.getSpouts()
if (items.isNotEmpty()) { CountingIdlingResourceSingleton.increment()
val itemsStrings = items.map { it.value.name } launch(Dispatchers.Main) {
for ((key, value) in items) { if (items.isNotEmpty()) {
spoutsKV[value.name] = key val itemsStrings = items.map { it.value.name }
for ((key, value) in items) {
spoutsKV[value.name] = key
}
binding.progress.visibility = View.GONE
binding.formContainer.visibility = View.VISIBLE
val spinnerArrayAdapter =
ArrayAdapter(
this@UpsertSourceActivity,
android.R.layout.simple_spinner_item,
itemsStrings,
)
spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spoutsSpinner.adapter = spinnerArrayAdapter
if (existingSource != null) {
initFields(items)
}
} else {
handleSpoutFailure()
} }
CountingIdlingResourceSingleton.decrement()
binding.progress.visibility = View.GONE
binding.formContainer.visibility = View.VISIBLE
val spinnerArrayAdapter =
ArrayAdapter(
this@UpsertSourceActivity,
android.R.layout.simple_spinner_item,
itemsStrings,
)
spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spoutsSpinner.adapter = spinnerArrayAdapter
if (existingSource != null) {
initFields(items)
}
} else {
handleSpoutFailure()
} }
} catch (e: NetworkUnavailableException) { } catch (e: NetworkUnavailableException) {
handleSpoutFailure(networkIssue = true) handleSpoutFailure(networkIssue = true)
} }
CountingIdlingResourceSingleton.decrement()
} }
} }
@ -160,7 +167,8 @@ class UpsertSourceActivity :
} }
else -> { else -> {
CoroutineScope(Dispatchers.Main).launch { CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.IO).launch {
val successfullyAddedSource = val successfullyAddedSource =
if (existingSource != null) { if (existingSource != null) {
repository.updateSource( repository.updateSource(
@ -178,16 +186,21 @@ class UpsertSourceActivity :
binding.tags.text.toString(), binding.tags.text.toString(),
) )
} }
if (successfullyAddedSource) { CountingIdlingResourceSingleton.increment()
finish() launch(Dispatchers.Main) {
} else { if (successfullyAddedSource) {
Toast finish()
.makeText( } else {
this@UpsertSourceActivity, Toast
R.string.cant_create_source, .makeText(
Toast.LENGTH_SHORT, this@UpsertSourceActivity,
).show() R.string.cant_create_source,
Toast.LENGTH_SHORT,
).show()
}
CountingIdlingResourceSingleton.decrement()
} }
CountingIdlingResourceSingleton.decrement()
} }
} }
} }

View File

@ -30,7 +30,7 @@ import org.kodein.di.instance
class ItemCardAdapter( class ItemCardAdapter(
override val app: Activity, override val app: Activity,
override val items: ArrayList<SelfossModel.Item>, override var items: ArrayList<SelfossModel.Item>,
override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit, override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit,
) : ItemsAdapter<ItemCardAdapter.ViewHolder>() { ) : ItemsAdapter<ItemCardAdapter.ViewHolder>() {
override lateinit var binding: CardItemBinding override lateinit var binding: CardItemBinding

View File

@ -21,7 +21,7 @@ import org.kodein.di.instance
class ItemListAdapter( class ItemListAdapter(
override val app: Activity, override val app: Activity,
override val items: ArrayList<SelfossModel.Item>, override var items: ArrayList<SelfossModel.Item>,
override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit, override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit,
) : ItemsAdapter<ItemListAdapter.ViewHolder>() { ) : ItemsAdapter<ItemListAdapter.ViewHolder>() {
override lateinit var binding: ListItemBinding override lateinit var binding: ListItemBinding

View File

@ -21,7 +21,7 @@ import org.kodein.di.DIAware
abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> :
RecyclerView.Adapter<VH>(), RecyclerView.Adapter<VH>(),
DIAware { DIAware {
abstract val items: ArrayList<SelfossModel.Item> abstract var items: ArrayList<SelfossModel.Item>
abstract val repository: Repository abstract val repository: Repository
abstract val binding: ViewBinding abstract val binding: ViewBinding
abstract val appSettingsService: AppSettingsService abstract val appSettingsService: AppSettingsService
@ -31,8 +31,7 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> :
protected val c: Context get() = app.baseContext protected val c: Context get() = app.baseContext
fun updateAllItems(items: ArrayList<SelfossModel.Item>) { fun updateAllItems(items: ArrayList<SelfossModel.Item>) {
this.items.clear() this.items = items
this.items.addAll(items)
updateHomeItems(items) updateHomeItems(items)
notifyDataSetChanged() notifyDataSetChanged()
} }

View File

@ -12,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import bou.amine.apps.readerforselfossv2.android.R import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.UpsertSourceActivity import bou.amine.apps.readerforselfossv2.android.UpsertSourceActivity
import bou.amine.apps.readerforselfossv2.android.databinding.SourceListItemBinding import bou.amine.apps.readerforselfossv2.android.databinding.SourceListItemBinding
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable
import bou.amine.apps.readerforselfossv2.model.SelfossModel import bou.amine.apps.readerforselfossv2.model.SelfossModel
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
@ -104,8 +105,10 @@ class SourcesListAdapter(
source: SelfossModel.SourceDetail, source: SelfossModel.SourceDetail,
position: Int, position: Int,
) { ) {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val successfullyDeletedSource = repository.deleteSource(source.id, source.title) val successfullyDeletedSource = repository.deleteSource(source.id, source.title)
CountingIdlingResourceSingleton.increment()
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
if (successfullyDeletedSource) { if (successfullyDeletedSource) {
items.removeAt(position) items.removeAt(position)
@ -119,7 +122,9 @@ class SourcesListAdapter(
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
CountingIdlingResourceSingleton.decrement()
} }
CountingIdlingResourceSingleton.decrement()
} }
} }
} }

View File

@ -42,6 +42,7 @@ import bou.amine.apps.readerforselfossv2.model.SelfossModel
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
import bou.amine.apps.readerforselfossv2.rest.MercuryApi import bou.amine.apps.readerforselfossv2.rest.MercuryApi
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
import bou.amine.apps.readerforselfossv2.service.ConnectivityService
import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded
import bou.amine.apps.readerforselfossv2.utils.getImages import bou.amine.apps.readerforselfossv2.utils.getImages
import bou.amine.apps.readerforselfossv2.utils.getThumbnail import bou.amine.apps.readerforselfossv2.utils.getThumbnail
@ -88,6 +89,7 @@ class ArticleFragment :
override val di: DI by closestDI() override val di: DI by closestDI()
private val repository: Repository by instance() private val repository: Repository by instance()
private val appSettingsService: AppSettingsService by instance() private val appSettingsService: AppSettingsService by instance()
private val connectivityService: ConnectivityService by instance()
private var typeface: Typeface? = null private var typeface: Typeface? = null
private var resId: Int = 0 private var resId: Int = 0
@ -168,7 +170,7 @@ class ArticleFragment :
private fun handleContent() { private fun handleContent() {
if (contentText.isEmptyOrNullOrNullString()) { if (contentText.isEmptyOrNullOrNullString()) {
if (repository.isNetworkAvailable() && url.isUrlValid()) { if (connectivityService.isNetworkAvailable() && url.isUrlValid()) {
getContentFromMercury(url!!) getContentFromMercury(url!!)
} }
} else { } else {
@ -261,13 +263,15 @@ class ArticleFragment :
) )
} }
private fun refreshAlignment() { fun refreshAlignment() {
textAlignment = textAlignment =
when (appSettingsService.getActiveAllignment()) { when (appSettingsService.getActiveAllignment()) {
1 -> "justify" 1 -> "justify"
2 -> "left" 2 -> "left"
else -> "justify" else -> "justify"
} }
htmlToWebview()
} }
@Suppress("detekt:SwallowedException") @Suppress("detekt:SwallowedException")

View File

@ -14,6 +14,7 @@ import android.view.ViewGroup
import bou.amine.apps.readerforselfossv2.android.HomeActivity import bou.amine.apps.readerforselfossv2.android.HomeActivity
import bou.amine.apps.readerforselfossv2.android.R import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.databinding.FilterFragmentBinding import bou.amine.apps.readerforselfossv2.android.databinding.FilterFragmentBinding
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import bou.amine.apps.readerforselfossv2.android.utils.acra.sendSilentlyWithAcraWithName import bou.amine.apps.readerforselfossv2.android.utils.acra.sendSilentlyWithAcraWithName
import bou.amine.apps.readerforselfossv2.android.utils.glide.imageIntoViewTarget import bou.amine.apps.readerforselfossv2.android.utils.glide.imageIntoViewTarget
import bou.amine.apps.readerforselfossv2.android.utils.maybeIfContext import bou.amine.apps.readerforselfossv2.android.utils.maybeIfContext
@ -59,12 +60,14 @@ class FilterSheetFragment :
) )
try { try {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
handleTagChips() handleTagChips()
handleSourceChips() handleSourceChips()
binding.progressBar2.visibility = GONE binding.progressBar2.visibility = GONE
binding.filterView.visibility = VISIBLE binding.filterView.visibility = VISIBLE
CountingIdlingResourceSingleton.decrement()
} }
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
dismiss() dismiss()

View File

@ -124,6 +124,7 @@ class SettingsActivity :
LibsBuilder() LibsBuilder()
.withAboutIconShown(true) .withAboutIconShown(true)
.withAboutVersionShown(true) .withAboutVersionShown(true)
.withShowLoadingProgress(false)
.start(it) .start(it)
} }
true true

View File

@ -1,32 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bou.amine.apps.readerforselfossv2.repository.Repository
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
class AppViewModel(
private val repository: Repository,
) : ViewModel() {
private val _networkAvailableProvider = MutableSharedFlow<Boolean>()
val networkAvailableProvider = _networkAvailableProvider.asSharedFlow()
private var wasConnected = true
init {
viewModelScope.launch {
repository.isConnectionAvailable.collect { isConnected ->
if (repository.connectionMonitored) {
if (isConnected && !wasConnected && repository.connectionMonitored) {
_networkAvailableProvider.emit(true)
wasConnected = true
} else if (!isConnected && wasConnected && repository.connectionMonitored) {
_networkAvailableProvider.emit(false)
wasConnected = false
}
}
}
}
}
}

View File

@ -1,3 +1,5 @@
@file:Suppress("ktlint")
/*
package bou.amine.apps.readerforselfossv2.android.tests.robolectric package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import android.view.Menu import android.view.Menu
@ -25,3 +27,4 @@ fun Menu.assertVisible(
val item = this.findItem(id) val item = this.findItem(id)
assertTrue(item.isVisible) assertTrue(item.isVisible)
} }
*/

View File

@ -1,3 +1,5 @@
@file:Suppress("ktlint")
/*
package bou.amine.apps.readerforselfossv2.android.tests.robolectric package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import android.widget.Button import android.widget.Button
@ -57,7 +59,8 @@ class LoginActivityTest {
} }
} }
/* @Test */
/* @Test
fun connect() { fun connect() {
Robolectric.buildActivity(LoginActivity::class.java).use { controller -> Robolectric.buildActivity(LoginActivity::class.java).use { controller ->
controller.setup() // Moves the Activity to the RESUMED state controller.setup() // Moves the Activity to the RESUMED state
@ -72,4 +75,7 @@ class LoginActivityTest {
assertEquals(expectedIntent.component, actual.component) assertEquals(expectedIntent.component, actual.component)
} }
}*/ }*/
/*
} }
*/

View File

@ -1,3 +1,5 @@
@file:Suppress("ktlint")
/*
package bou.amine.apps.readerforselfossv2.android.tests.robolectric package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@ -8,3 +10,4 @@ class RobotElectriqueRunner(
) : RobolectricTestRunner(testClass) { ) : RobolectricTestRunner(testClass) {
override fun buildGlobalConfig(): Config = Config.Builder().setSdk(25, 30, 33).build() override fun buildGlobalConfig(): Config = Config.Builder().setSdk(25, 30, 33).build()
} }
*/

View File

@ -11,6 +11,7 @@ import bou.amine.apps.readerforselfossv2.model.SuccessResponse
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
import bou.amine.apps.readerforselfossv2.rest.SelfossApi import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
import bou.amine.apps.readerforselfossv2.service.ConnectivityService
import bou.amine.apps.readerforselfossv2.utils.ItemType import bou.amine.apps.readerforselfossv2.utils.ItemType
import bou.amine.apps.readerforselfossv2.utils.toView import bou.amine.apps.readerforselfossv2.utils.toView
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
@ -24,7 +25,6 @@ import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotSame import junit.framework.TestCase.assertNotSame
import junit.framework.TestCase.assertSame import junit.framework.TestCase.assertSame
import junit.framework.TestCase.assertTrue import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Before import org.junit.Before
@ -52,15 +52,12 @@ class RepositoryTest {
private val db = mockk<ReaderForSelfossDB>(relaxed = true) private val db = mockk<ReaderForSelfossDB>(relaxed = true)
private val appSettingsService = mockk<AppSettingsService>() private val appSettingsService = mockk<AppSettingsService>()
private val api = mockk<SelfossApi>() private val api = mockk<SelfossApi>()
private val connectivityService = mockk<ConnectivityService>()
private lateinit var repository: Repository private lateinit var repository: Repository
private fun initializeRepository( private fun initializeRepository(isNetworkAvailable: Boolean = true) {
isConnectionAvailable: MutableStateFlow<Boolean> = every { connectivityService.isNetworkAvailable() } returns isNetworkAvailable
MutableStateFlow( repository = Repository(api, appSettingsService, connectivityService, db)
true,
),
) {
repository = Repository(api, appSettingsService, isConnectionAvailable, db)
runBlocking { runBlocking {
repository.updateApiInformation() repository.updateApiInformation()
@ -110,7 +107,7 @@ class RepositoryTest {
fun instantiate_repository_without_api_version() { fun instantiate_repository_without_api_version() {
every { appSettingsService.getApiVersion() } returns -1 every { appSettingsService.getApiVersion() } returns -1
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
coVerify(exactly = 0) { api.apiInformation() } coVerify(exactly = 0) { api.apiInformation() }
coVerify(exactly = 0) { api.stats() } coVerify(exactly = 0) { api.stats() }
@ -287,7 +284,7 @@ class RepositoryTest {
fun get_newer_items_without_connectivity() { fun get_newer_items_without_connectivity() {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
runBlocking { runBlocking {
repository.getNewerItems() repository.getNewerItems()
} }
@ -314,7 +311,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
repository.setTagFilter(SelfossModel.Tag("Test", "red", 3)) repository.setTagFilter(SelfossModel.Tag("Test", "red", 3))
runBlocking { runBlocking {
repository.getNewerItems() repository.getNewerItems()
@ -342,7 +339,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
repository.setSourceFilter( repository.setSourceFilter(
SelfossModel.SourceDetail( SelfossModel.SourceDetail(
1, 1,
@ -457,7 +454,7 @@ class RepositoryTest {
var success: Boolean var success: Boolean
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
runBlocking { runBlocking {
success = repository.reloadBadges() success = repository.reloadBadges()
} }
@ -477,7 +474,7 @@ class RepositoryTest {
var success: Boolean var success: Boolean
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
runBlocking { runBlocking {
success = repository.reloadBadges() success = repository.reloadBadges()
} }
@ -572,7 +569,7 @@ class RepositoryTest {
every { appSettingsService.isUpdateSourcesEnabled() } returns true every { appSettingsService.isUpdateSourcesEnabled() } returns true
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testTags: List<SelfossModel.Tag> var testTags: List<SelfossModel.Tag>
runBlocking { runBlocking {
testTags = repository.getTags() testTags = repository.getTags()
@ -590,7 +587,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns true every { appSettingsService.isUpdateSourcesEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testTags: List<SelfossModel.Tag> var testTags: List<SelfossModel.Tag>
runBlocking { runBlocking {
testTags = repository.getTags() testTags = repository.getTags()
@ -607,7 +604,7 @@ class RepositoryTest {
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testTags: List<SelfossModel.Tag> var testTags: List<SelfossModel.Tag>
runBlocking { runBlocking {
testTags = repository.getTags() testTags = repository.getTags()
@ -625,7 +622,7 @@ class RepositoryTest {
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testTags: List<SelfossModel.Tag> var testTags: List<SelfossModel.Tag>
runBlocking { runBlocking {
testTags = repository.getTags() testTags = repository.getTags()
@ -775,7 +772,7 @@ class RepositoryTest {
@Test @Test
fun get_sources_without_connection() { fun get_sources_without_connection() {
val (_, sourcesDB) = prepareSources() val (_, sourcesDB) = prepareSources()
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testSources: List<SelfossModel.Source> var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSourcesDetails() testSources = repository.getSourcesDetails()
@ -792,7 +789,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns true every { appSettingsService.isUpdateSourcesEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testSources: List<SelfossModel.Source> var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSourcesDetails() testSources = repository.getSourcesDetails()
@ -809,7 +806,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testSources: List<SelfossModel.Source> var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSourcesDetails() testSources = repository.getSourcesDetails()
@ -826,7 +823,7 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var testSources: List<SelfossModel.Source> var testSources: List<SelfossModel.Source>
runBlocking { runBlocking {
testSources = repository.getSourcesDetails() testSources = repository.getSourcesDetails()
@ -898,7 +895,7 @@ class RepositoryTest {
coEvery { api.createSourceForVersion(any(), any(), any(), any()) } returns coEvery { api.createSourceForVersion(any(), any(), any(), any()) } returns
SuccessResponse(true) SuccessResponse(true)
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = response =
@ -955,7 +952,7 @@ class RepositoryTest {
fun delete_source_without_connection() { fun delete_source_without_connection() {
coEvery { api.deleteSource(any()) } returns SuccessResponse(false) coEvery { api.deleteSource(any()) } returns SuccessResponse(false)
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.deleteSource(5, "src") response = repository.deleteSource(5, "src")
@ -1028,7 +1025,7 @@ class RepositoryTest {
data = "undocumented...", data = "undocumented...",
) )
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.updateRemote() response = repository.updateRemote()
@ -1070,7 +1067,7 @@ class RepositoryTest {
fun login_but_without_connection() { fun login_but_without_connection() {
coEvery { api.login() } returns SuccessResponse(success = true) coEvery { api.login() } returns SuccessResponse(success = true)
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.login() response = repository.login()
@ -1150,7 +1147,7 @@ class RepositoryTest {
coEvery { api.getItems(any(), any(), any(), any(), any(), any(), any()) } returns coEvery { api.getItems(any(), any(), any(), any(), any(), any(), any()) } returns
StatusAndData(success = false, data = generateTestApiItem()) StatusAndData(success = false, data = generateTestApiItem())
initializeRepository(MutableStateFlow(false)) initializeRepository(false)
prepareSearch() prepareSearch()
runBlocking { runBlocking {
repository.tryToCacheItemsAndGetNewOnes() repository.tryToCacheItemsAndGetNewOnes()

View File

@ -0,0 +1,12 @@
**v125030681**
- chore: do not send reports on simulators.
- Merge pull request 'chore: do not send reports on simulators.' (#188) from chore-acra-simulator into master
- chore: do not send reports on simulators.
- Merge pull request 'fix: Url validation was not failing login. Added tests.' (#186) from fix-invalid-url into master
- Merge pull request 'chore: crowding ci integration.' (#187) from chore-crowdin-ci into master
- chore: we don't need to check if the url is valid in upsert screen.
- fix: Url validation was not failing login. Added tests.
- chore: crowding ci integration.
- Show a confirmation dialog before deleting sources (#185)
- Changelog for v125020581

View File

@ -0,0 +1,8 @@
**v125030711**
- Merge pull request 'fix: initial status loading issues.' (#192) from connectivity into master
- chore: check changes for translations and android.
- fix: initial status loading issues.
- Merge pull request 'chore: new connectivity dep. Closes #84.' (#189) from connectivity into master
- chore: new connectivity dep. Closes #84.
- Changelog for v125030681

View File

@ -0,0 +1,17 @@
**v125030901**
- Merge pull request 'fix-reload' (#195) from fix-reload into master
- fix: Infinite scroll needs loading stats.
- fix: do not reload items on resume.
- Merge pull request 'tests' (#193) from tests into master
- ci: Instrumentation tests coverage in ci.
- ci: Instrumentation tests coverage in ci.
- ci: Instrumentation tests coverage in ci.
- chore: better handling of coroutine dispatchers.
- ci: Instrumentation tests coverage in ci.
- chore: comment robolectric tests for now.
- fix: Fixed source deletion test.
- Merge pull request 'Fix alignment changes resetting reader article position' (#190) from davidoskky/ReaderForSelfoss-multiplatform:alignment into master
- Refactor star icon handling
- Don't restart activity changing alignment
- Changelog for v125030711

View File

@ -27,3 +27,4 @@ org.gradle.caching=true
ignoreGitVersion=false ignoreGitVersion=false
kotlin.native.cacheKind.iosX64=none kotlin.native.cacheKind.iosX64=none
org.gradle.configureondemand=true org.gradle.configureondemand=true
kotlin.jvm.target.validation.mode=IGNORE

View File

@ -4,7 +4,6 @@ object SqlDelight {
const val runtime = "app.cash.sqldelight:runtime:2.0.2" const val runtime = "app.cash.sqldelight:runtime:2.0.2"
const val android = "app.cash.sqldelight:android-driver:2.0.2" const val android = "app.cash.sqldelight:android-driver:2.0.2"
const val native = "app.cash.sqldelight:native-driver:2.0.2" const val native = "app.cash.sqldelight:native-driver:2.0.2"
} }
plugins { plugins {
@ -41,13 +40,13 @@ kotlin {
implementation("org.jsoup:jsoup:1.15.4") implementation("org.jsoup:jsoup:1.15.4")
//Dependency Injection // Dependency Injection
implementation("org.kodein.di:kodein-di:7.14.0") implementation("org.kodein.di:kodein-di:7.14.0")
//Settings // Settings
implementation("com.russhwolf:multiplatform-settings-no-arg:1.0.0-RC") implementation("com.russhwolf:multiplatform-settings-no-arg:1.0.0-RC")
//Logging // Logging
implementation("io.github.aakira:napier:2.6.1") implementation("io.github.aakira:napier:2.6.1")
// Sql // Sql
@ -55,6 +54,10 @@ kotlin {
// Sql // Sql
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
// Connectivity
implementation("dev.jordond.connectivity:connectivity-core:1.2.0")
implementation("dev.jordond.connectivity:connectivity-device:1.2.0")
} }
} }
val commonTest by getting { val commonTest by getting {
@ -114,4 +117,4 @@ sqldelight {
packageName.set("bou.amine.apps.readerforselfossv2.dao") packageName.set("bou.amine.apps.readerforselfossv2.dao")
} }
} }
} }

View File

@ -13,6 +13,7 @@ import bou.amine.apps.readerforselfossv2.model.SelfossModel
import bou.amine.apps.readerforselfossv2.model.StatusAndData import bou.amine.apps.readerforselfossv2.model.StatusAndData
import bou.amine.apps.readerforselfossv2.rest.SelfossApi import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
import bou.amine.apps.readerforselfossv2.service.ConnectivityService
import bou.amine.apps.readerforselfossv2.utils.ItemType import bou.amine.apps.readerforselfossv2.utils.ItemType
import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded
import bou.amine.apps.readerforselfossv2.utils.toEntity import bou.amine.apps.readerforselfossv2.utils.toEntity
@ -30,11 +31,10 @@ private const val MAX_ITEMS_NUMBER = 200
class Repository( class Repository(
private val api: SelfossApi, private val api: SelfossApi,
private val appSettingsService: AppSettingsService, private val appSettingsService: AppSettingsService,
val isConnectionAvailable: MutableStateFlow<Boolean>, private val connectivityService: ConnectivityService,
private val db: ReaderForSelfossDB, private val db: ReaderForSelfossDB,
) { ) {
var items = ArrayList<SelfossModel.Item>() var items = ArrayList<SelfossModel.Item>()
var connectionMonitored = false
var baseUrl = appSettingsService.getBaseUrl() var baseUrl = appSettingsService.getBaseUrl()
@ -63,7 +63,7 @@ class Repository(
suspend fun getNewerItems(): ArrayList<SelfossModel.Item> { suspend fun getNewerItems(): ArrayList<SelfossModel.Item> {
var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error() var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error()
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
fetchedItems = fetchedItems =
api.getItems( api.getItems(
displayedItems.type, displayedItems.type,
@ -102,7 +102,7 @@ class Repository(
suspend fun getOlderItems(): ArrayList<SelfossModel.Item> { suspend fun getOlderItems(): ArrayList<SelfossModel.Item> {
var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error() var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error()
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val offset = items.size val offset = items.size
fetchedItems = fetchedItems =
api.getItems( api.getItems(
@ -122,7 +122,7 @@ class Repository(
} }
private suspend fun getMaxItemsForBackground(itemType: ItemType): List<SelfossModel.Item> { private suspend fun getMaxItemsForBackground(itemType: ItemType): List<SelfossModel.Item> {
return if (isNetworkAvailable()) { return if (connectivityService.isNetworkAvailable()) {
val items = val items =
api.getItems( api.getItems(
itemType.type, itemType.type,
@ -146,7 +146,7 @@ class Repository(
@Suppress("detekt:ForbiddenComment") @Suppress("detekt:ForbiddenComment")
suspend fun reloadBadges(): Boolean { suspend fun reloadBadges(): Boolean {
var success = false var success = false
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val response = api.stats() val response = api.stats()
if (response.success && response.data != null) { if (response.success && response.data != null) {
_badgeUnread.value = response.data.unread ?: 0 _badgeUnread.value = response.data.unread ?: 0
@ -168,7 +168,7 @@ class Repository(
suspend fun getTags(): List<SelfossModel.Tag> { suspend fun getTags(): List<SelfossModel.Tag> {
val isDatabaseEnabled = val isDatabaseEnabled =
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled() appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
return if (isNetworkAvailable() && !fetchedTags) { return if (connectivityService.isNetworkAvailable() && !fetchedTags) {
val apiTags = api.tags() val apiTags = api.tags()
if (apiTags.success && apiTags.data != null && isDatabaseEnabled) { if (apiTags.success && apiTags.data != null && isDatabaseEnabled) {
resetDBTagsWithData(apiTags.data) resetDBTagsWithData(apiTags.data)
@ -185,7 +185,7 @@ class Repository(
} }
suspend fun getSpouts(): Map<String, SelfossModel.Spout> = suspend fun getSpouts(): Map<String, SelfossModel.Spout> =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val spouts = api.spouts() val spouts = api.spouts()
if (spouts.success && spouts.data != null) { if (spouts.success && spouts.data != null) {
spouts.data spouts.data
@ -201,7 +201,7 @@ class Repository(
val isDatabaseEnabled = val isDatabaseEnabled =
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled() appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true
if (shouldFetch && isNetworkAvailable()) { if (shouldFetch && connectivityService.isNetworkAvailable()) {
if (appSettingsService.getPublicAccess()) { if (appSettingsService.getPublicAccess()) {
val apiSources = api.sourcesStats() val apiSources = api.sourcesStats()
if (apiSources.success && apiSources.data != null) { if (apiSources.success && apiSources.data != null) {
@ -223,17 +223,26 @@ class Repository(
val isDatabaseEnabled = val isDatabaseEnabled =
appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled() appSettingsService.isItemCachingEnabled() || !appSettingsService.isUpdateSourcesEnabled()
val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true val shouldFetch = if (!appSettingsService.isUpdateSourcesEnabled()) !fetchedSources else true
if (shouldFetch && isNetworkAvailable()) { if (shouldFetch && connectivityService.isNetworkAvailable()) {
val apiSources = api.sourcesDetailed() sources = sourceDetails(isDatabaseEnabled)
if (apiSources.success && apiSources.data != null) {
fetchedSources = true
sources = apiSources.data
if (isDatabaseEnabled) {
resetDBSourcesWithData(sources)
}
}
} else if (isDatabaseEnabled) { } else if (isDatabaseEnabled) {
sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.SourceDetail> sources = getDBSources().map { it.toView() } as ArrayList<SelfossModel.SourceDetail>
if (sources.isEmpty() && !connectivityService.isNetworkAvailable() && !fetchedSources) {
sources = sourceDetails(isDatabaseEnabled)
}
}
return sources
}
private suspend fun sourceDetails(isDatabaseEnabled: Boolean): ArrayList<SelfossModel.SourceDetail> {
var sources = ArrayList<SelfossModel.SourceDetail>()
val apiSources = api.sourcesDetailed()
if (apiSources.success && apiSources.data != null) {
fetchedSources = true
sources = apiSources.data
if (isDatabaseEnabled) {
resetDBSourcesWithData(sources)
}
} }
return sources return sources
} }
@ -248,7 +257,7 @@ class Repository(
} }
private suspend fun markAsReadById(id: Int): Boolean = private suspend fun markAsReadById(id: Int): Boolean =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
api.markAsRead(id.toString()).isSuccess api.markAsRead(id.toString()).isSuccess
} else { } else {
insertDBAction(id.toString(), read = true) insertDBAction(id.toString(), read = true)
@ -265,7 +274,7 @@ class Repository(
} }
private suspend fun unmarkAsReadById(id: Int): Boolean = private suspend fun unmarkAsReadById(id: Int): Boolean =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
api.unmarkAsRead(id.toString()).isSuccess api.unmarkAsRead(id.toString()).isSuccess
} else { } else {
insertDBAction(id.toString(), unread = true) insertDBAction(id.toString(), unread = true)
@ -282,7 +291,7 @@ class Repository(
} }
private suspend fun starrById(id: Int): Boolean = private suspend fun starrById(id: Int): Boolean =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
api.starr(id.toString()).isSuccess api.starr(id.toString()).isSuccess
} else { } else {
insertDBAction(id.toString(), starred = true) insertDBAction(id.toString(), starred = true)
@ -299,7 +308,7 @@ class Repository(
} }
private suspend fun unstarrById(id: Int): Boolean = private suspend fun unstarrById(id: Int): Boolean =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
api.unstarr(id.toString()).isSuccess api.unstarr(id.toString()).isSuccess
} else { } else {
insertDBAction(id.toString(), starred = true) insertDBAction(id.toString(), starred = true)
@ -309,7 +318,8 @@ class Repository(
suspend fun markAllAsRead(items: ArrayList<SelfossModel.Item>): Boolean { suspend fun markAllAsRead(items: ArrayList<SelfossModel.Item>): Boolean {
var success = false var success = false
if (isNetworkAvailable() && api.markAllAsRead(items.map { it.id.toString() }).isSuccess) { if (connectivityService.isNetworkAvailable() && api.markAllAsRead(items.map { it.id.toString() }).isSuccess
) {
success = true success = true
for (item in items) { for (item in items) {
markAsReadLocally(item) markAsReadLocally(item)
@ -324,7 +334,7 @@ class Repository(
_badgeUnread.value -= 1 _badgeUnread.value -= 1
} }
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Default).launch {
updateDBItem(item) updateDBItem(item)
} }
} }
@ -335,7 +345,7 @@ class Repository(
_badgeUnread.value += 1 _badgeUnread.value += 1
} }
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Default).launch {
updateDBItem(item) updateDBItem(item)
} }
} }
@ -346,7 +356,7 @@ class Repository(
_badgeStarred.value += 1 _badgeStarred.value += 1
} }
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Default).launch {
updateDBItem(item) updateDBItem(item)
} }
} }
@ -357,7 +367,7 @@ class Repository(
_badgeStarred.value -= 1 _badgeStarred.value -= 1
} }
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Default).launch {
updateDBItem(item) updateDBItem(item)
} }
} }
@ -369,7 +379,8 @@ class Repository(
tags: String, tags: String,
): Boolean { ): Boolean {
var response = false var response = false
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
fetchedSources = false
response = api response = api
.createSourceForVersion( .createSourceForVersion(
title, title,
@ -390,7 +401,8 @@ class Repository(
tags: String, tags: String,
): Boolean { ): Boolean {
var response = false var response = false
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
fetchedSources = false
response = api.updateSourceForVersion(id, title, url, spout, tags).isSuccess == true response = api.updateSourceForVersion(id, title, url, spout, tags).isSuccess == true
} }
@ -402,13 +414,14 @@ class Repository(
title: String, title: String,
): Boolean { ): Boolean {
var success = false var success = false
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val response = api.deleteSource(id) val response = api.deleteSource(id)
success = response.isSuccess success = response.isSuccess
fetchedSources = false
} }
// We filter on success or if the network isn't available // We filter on success or if the network isn't available
if (success || !isNetworkAvailable()) { if (success || !connectivityService.isNetworkAvailable()) {
items = ArrayList(items.filter { it.sourcetitle != title }) items = ArrayList(items.filter { it.sourcetitle != title })
setReaderItems(items) setReaderItems(items)
db.itemsQueries.deleteItemsWhereSource(title) db.itemsQueries.deleteItemsWhereSource(title)
@ -418,7 +431,7 @@ class Repository(
} }
suspend fun updateRemote(): Boolean = suspend fun updateRemote(): Boolean =
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
api.update().data.equals("finished") api.update().data.equals("finished")
} else { } else {
false false
@ -426,7 +439,7 @@ class Repository(
suspend fun login(): Boolean { suspend fun login(): Boolean {
var result = false var result = false
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
try { try {
val response = api.login() val response = api.login()
result = response.isSuccess == true result = response.isSuccess == true
@ -439,7 +452,7 @@ class Repository(
suspend fun checkIfFetchFails(): Boolean { suspend fun checkIfFetchFails(): Boolean {
var fetchFailed = true var fetchFailed = true
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
try { try {
// Trying to fetch one item, and check someone is trying to use the app with // Trying to fetch one item, and check someone is trying to use the app with
// a random rss feed, that would throw a NoTransformationFoundException // a random rss feed, that would throw a NoTransformationFoundException
@ -453,7 +466,7 @@ class Repository(
} }
suspend fun logout() { suspend fun logout() {
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
try { try {
val response = api.logout() val response = api.logout()
if (!response.isSuccess) { if (!response.isSuccess) {
@ -481,7 +494,7 @@ class Repository(
suspend fun updateApiInformation() { suspend fun updateApiInformation() {
val apiMajorVersion = appSettingsService.getApiVersion() val apiMajorVersion = appSettingsService.getApiVersion()
if (isNetworkAvailable()) { if (connectivityService.isNetworkAvailable()) {
val fetchedInformation = api.apiInformation() val fetchedInformation = api.apiInformation()
if (fetchedInformation.success && fetchedInformation.data != null) { if (fetchedInformation.success && fetchedInformation.data != null) {
if (fetchedInformation.data.getApiMajorVersion() != apiMajorVersion) { if (fetchedInformation.data.getApiMajorVersion() != apiMajorVersion) {
@ -500,8 +513,6 @@ class Repository(
} }
} }
fun isNetworkAvailable() = isConnectionAvailable.value && !offlineOverride
private fun getDBActions(): List<ACTION> = db.actionsQueries.actions().executeAsList() private fun getDBActions(): List<ACTION> = db.actionsQueries.actions().executeAsList()
private fun deleteDBAction(action: ACTION) = db.actionsQueries.deleteAction(action.id) private fun deleteDBAction(action: ACTION) = db.actionsQueries.deleteAction(action.id)

View File

@ -32,6 +32,7 @@ import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.toByteArray import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -82,7 +83,7 @@ class SelfossApi(
} }
modifyRequest { modifyRequest {
Napier.i("Will modify", tag = "HttpSend") Napier.i("Will modify", tag = "HttpSend")
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.IO).launch {
Napier.i("Will login", tag = "HttpSend") Napier.i("Will login", tag = "HttpSend")
login() login()
Napier.i("Did login", tag = "HttpSend") Napier.i("Did login", tag = "HttpSend")

View File

@ -0,0 +1,46 @@
package bou.amine.apps.readerforselfossv2.service
import dev.jordond.connectivity.Connectivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
class ConnectivityService {
private val _networkAvailableProvider = MutableSharedFlow<Boolean>()
val networkAvailableProvider = _networkAvailableProvider.asSharedFlow()
private var currentStatus = true
private lateinit var connectivity: Connectivity
fun start() {
connectivity = Connectivity()
connectivity.start()
CoroutineScope(Dispatchers.Default).launch {
connectivity.statusUpdates.collect { status ->
when (status) {
is Connectivity.Status.Connected -> {
if (!currentStatus) {
currentStatus = true
_networkAvailableProvider.emit(true)
}
}
is Connectivity.Status.Disconnected -> {
if (currentStatus) {
currentStatus = false
_networkAvailableProvider.emit(false)
}
}
}
}
}
}
fun isNetworkAvailable(): Boolean = currentStatus
fun stop() {
currentStatus = true
connectivity.stop()
}
}