Compare commits

..

42 Commits

Author SHA1 Message Date
43ae1259a2 fix: Displaying fixes. Fixes #155
All checks were successful
Check PR code / Lint (pull_request) Successful in 58s
Check PR code / build (pull_request) Successful in 9m18s
2024-12-29 21:57:29 +01:00
f54fcc3ba1 test: coverage
All checks were successful
Check PR code / Lint (pull_request) Successful in 1m13s
Check PR code / build (pull_request) Successful in 9m43s
2024-12-29 21:35:12 +01:00
aad93ef722 chore: update and use multiplatform datetime 2024-12-21 20:32:58 +01:00
9e83af0302 Changelog for v124123421
All checks were successful
Check master code / build (push) Successful in 11m23s
2024-12-07 17:41:21 +00:00
24b86e66b4 fix: Trying to fix the serialization issue.
All checks were successful
Check master code / build (push) Successful in 9m40s
Create tag / build (push) Successful in 8m10s
Create tag / createTagAndChangelog (push) Successful in 32s
Create tag / release (push) Successful in 6m48s
2024-12-07 18:20:49 +01:00
641c444061 Changelog for v124113311 2024-11-26 20:40:47 +00:00
0902c61544 chore: update versions. (#165)
All checks were successful
Check master code / build (push) Successful in 8m8s
Create tag / build (push) Successful in 8m15s
Create tag / createTagAndChangelog (push) Successful in 1m0s
Create tag / release (push) Successful in 6m52s
## Types of changes

- [ ] I have read the **CONTRIBUTING** document.
- [ ] My code follows the code style of this project.
- [ ] I have updated the documentation accordingly.
- [ ] I have added tests to cover my changes.
- [ ] All new and existing tests passed.
- [ ] This is **NOT** translation related.

This closes issue #XXX

This is implements feature #YYY

This finishes chore #ZZZ

Co-authored-by: aminecmi <aminecmi@gmail.com>
Reviewed-on: #165
Co-authored-by: administrateur <administrateur@hidden.hidden>
Co-committed-by: administrateur <administrateur@hidden.hidden>
2024-11-26 20:19:43 +00:00
6790152a0b chore: fastlane changelog.
All checks were successful
Check master code / build (push) Successful in 8m3s
2024-11-26 21:06:57 +01:00
46d1ba418e chore: fastlane fixes.
Some checks failed
Check master code / build (push) Has been cancelled
2024-11-26 21:03:31 +01:00
436373d0ad Changelog for v124113301
All checks were successful
Check master code / build (push) Successful in 7m42s
2024-11-25 22:23:23 +01:00
5b9b51c02d chore: Gitea Action
All checks were successful
Check master code / build (push) Successful in 7m51s
Create tag / build (push) Successful in 8m15s
Create tag / createTagAndChangelog (push) Successful in 46s
Create tag / release (push) Successful in 6m27s
2024-11-25 21:55:48 +01:00
b81abe384a Merge pull request 'chore: Gitea Action' (#164) from runner into master
Some checks failed
Push/PR Steps / BuildAndTest (push) Has been cancelled
Reviewed-on: #164
2024-11-23 14:14:45 +00:00
851f862dbe chore: Gitea Action
All checks were successful
Push/PR Steps / Lint (pull_request) Successful in 1m0s
Push/PR Steps / BuildAndTest (pull_request) Successful in 7m49s
2024-11-23 14:54:45 +01:00
8d7e302af8 chore: Readme update. 2024-11-20 20:45:06 +01:00
236e1cca90 Fix recycleview article positions (#163)
Reviewed-on: #163
Reviewed-by: Amine Bouabdallaoui <amineb@hidden.hidden>
2024-11-20 09:32:29 +01:00
3a33cb4510 Provide method to update items in the home
Removed in the previous commit, the item adapter accepts a method to update the articles list in the home page.
Renamed the method to make its objective more clear.
Removed a debugging log.
Reverted change to function name.
2024-11-20 01:28:47 +01:00
0bf9ca9a49 Fix recycleview article positions
The articles were being opened by setting a click listener on the binding.
In card view this was being done through a function and as such it used the overall viewbind of the view rather than the binding of the item viewholder.
Now the functions are more explicit to avoid future errors.
Pulled up a few members from ItemCardAdapter and ItemListAdapter to ItemAdapter.
2024-11-19 01:50:58 +01:00
61e0087894 Changelog for v124041081 [CI SKIP] 2024-04-17 10:57:12 +00:00
1ec05d9913 chore: comment.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
continuous-integration/drone/tag Build is passing
2024-04-17 12:39:45 +02:00
859bd91bbb fix: Last time fixing the parsing date hack before moving it to os version. 2024-04-17 12:22:33 +02:00
204b736c53 Changelog for v124030731 [CI SKIP] 2024-03-13 19:51:14 +00:00
f24609c143 fix: Basic auth and password can have non whitspace characters. Fixes 142.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-03-13 20:31:24 +01:00
b94d7dc537 Changelog for v124020451 [CI SKIP] 2024-02-14 19:54:05 +00:00
41910cc4cd fix: Fixed handling of position in card adapter.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-02-13 21:31:02 +01:00
db166ca9d4 Changelog for v124010301 [CI SKIP] 2024-01-30 19:36:37 +00:00
db0d5a4a85 fix: This may fix the oom errors.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-01-29 20:55:29 +01:00
3bc0d7cf95 Changelog for v124010191 [CI SKIP] 2024-01-19 21:16:23 +00:00
8f464d95fd fix: moving listeners.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-01-19 21:32:43 +01:00
5ccd6a3368 chore: removed a useless log. 2024-01-19 21:32:43 +01:00
cdbded246e Changelog for v124010032 [CI SKIP] 2024-01-03 22:16:35 +00:00
750c7758bd fix: Another date format thing.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-01-03 23:07:57 +01:00
22f8b14ecd Changelog for v124010031 [CI SKIP] 2024-01-03 21:46:48 +00:00
6e27d6d4e6 fix: Checking if selfoss instance.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2024-01-03 22:28:57 +01:00
14ff4dbd05 fix: handle three characters lenght hexcode colors. 2024-01-03 21:35:58 +01:00
390c2d0cf3 Changelog for v123113311 [CI SKIP] 2023-11-27 20:48:32 +00:00
e58914ef58 chore: Source tracker url in the menu.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2023-11-27 21:31:46 +01:00
a03f08fca1 fix: Handle kodein proguard rules. 2023-11-27 21:31:26 +01:00
8e9b87f00c Changelog for v123102961 [CI SKIP] 2023-10-23 21:11:15 +00:00
f765224a86 chore: domain changes.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2023-10-21 23:41:17 +02:00
14d2219eb8 Changelog for v123102852 [CI SKIP] 2023-10-12 18:48:06 +00:00
137580ccf9 chore: lint cleaning.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2023-10-12 20:38:01 +02:00
f101d22f54 Changelog for v123102841 [CI SKIP] 2023-10-12 20:13:17 +02:00
105 changed files with 3756 additions and 1768 deletions

View File

@ -1,170 +0,0 @@
kind: pipeline
type: docker
name: test
steps:
- name: Lint
failure: ignore
image: mingc/android-build-box:latest
commands:
- echo "---------------------------------------------------------"
- echo "Install linters..."
- curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.0.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/
- curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.1/detekt-cli-1.23.1.zip && unzip detekt-cli-1.23.1.zip
- echo "---------------------------------------------------------"
- echo "Linting..."
- ktlint || true
- echo "---------------------------------------------------------"
- echo "Detecting..."
- ./detekt-cli-1.23.1/bin/detekt-cli --all-rules || true
- echo "---------------------------------------------------------"
command_timeout: 1m
- name: BuildAndTest
image: mingc/android-build-box:latest
commands:
- echo "---------------------------------------------------------"
- echo "Configure gradle..."
- mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true\nsystemProp.org.gradle.internal.http.connectionTimeout=180000\nsystemProp.org.gradle.internal.http.socketTimeout=180000" >> ~/.gradle/gradle.properties
- echo "---------------------------------------------------------"
- echo "Configure java..."
- . ~/.bash_profile
- jenv global 17.0
- java --version
- date
- echo "---------------------------------------------------------"
- echo "Building and testing..."
- ./gradlew build
- echo "---------------------------------------------------------"
trigger:
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: Publish
steps:
- name: createTagAndChangelog
image: ubuntu:latest
commands:
- apt-get update && apt-get install -y git
- git fetch --tags -p
- PREV=$(git describe --tags --abbrev=0)
- ./build.sh --publish --from-ci
- VER=$(git describe --tags --abbrev=0)
- CHANGELOG=$(git log $PREV..HEAD --pretty="- %s")
- echo "**$VER**\n\n$CHANGELOG\n\n--------------------------------------------------------------------\n\n$(cat CHANGELOG.md)" > CHANGELOG.md
- git add CHANGELOG.md
- git commit -m "Changelog for $VER [CI SKIP]"
environment:
TZ: Europe/Paris
- name: git-push
image: appleboy/drone-git-push
settings:
branch: master
remote:
from_secret: remoteUrl
followtags: true
ssh_key:
from_secret: privateKey
skip_verify: true
- name: scpFiles
image: appleboy/drone-scp
settings:
host: amine-louveau.fr
username: ubuntu
key:
from_secret: privateKey
port: 22
target: /home/ubuntu/
source: version.txt
- name: deploy
image: appleboy/drone-ssh
settings:
host: amine-louveau.fr
user: ubuntu
key:
from_secret: privateKey
command_timeout: 2m
script:
- cd /home/ubuntu && sudo rm -rf /var/www/amine/version.txt && sudo chown www-data:www-data ./version.txt && sudo mv version.txt /var/www/amine/
trigger:
event:
- promote
target:
- production
---
kind: pipeline
type: docker
name: Release
steps:
- name: build
image: mingc/android-build-box:latest
commands:
- echo "---------------------------------------------------------"
- echo "Fetch tags..."
- git fetch --tags
- echo "---------------------------------------------------------"
- echo "Configure gradle..."
- mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=false\nsystemProp.org.gradle.internal.http.connectionTimeout=180000\nsystemProp.org.gradle.internal.http.socketTimeout=180000" >> ~/.gradle/gradle.properties
- echo "---------------------------------------------------------"
- echo "Generate APK"
- ./gradlew :androidApp:assembleGithubConfigRelease
- echo "---------------------------------------------------------"
- echo "Get Key"
- wget https://amine-louveau.fr/key
- echo "---------------------------------------------------------"
- echo "Zipalign"
- $ANDROID_HOME/build-tools/31.0.0/zipalign -f -v 4 androidApp/build/outputs/apk/githubConfig/release/androidApp-githubConfig-release-unsigned.apk androidApp/build/outputs/apk/githubConfig/release/android-prod-released-ziped.apk
- echo "---------------------------------------------------------"
- echo "Sign"
- $ANDROID_HOME/build-tools/31.0.0/apksigner sign -v --out signed.apk --ks ./key --ks-key-alias $YOUR_KEY_ALIAS --ks-pass pass:$YOUR_KEYSTORE_PASSWORD --v1-signing-enabled true --v2-signing-enabled true androidApp/build/outputs/apk/githubConfig/release/android-prod-released-ziped.apk
- echo "---------------------------------------------------------"
- echo "Verify"
- $ANDROID_HOME/build-tools/31.0.0/apksigner verify signed.apk
environment:
TZ: Europe/Paris
YOUR_KEYSTORE_PASSWORD:
from_secret: keyPass
YOUR_KEY_ALIAS:
from_secret: keyAlias
- name: gitea_release
image: plugins/gitea-release
settings:
api_key:
from_secret: giteaAPI
base_url: https://gitea.amine-louveau.fr
files: signed.apk
- name: notify
image: drillster/drone-email
failure: ignore
settings:
host:
from_secret: smtpHOST
port:
from_secret: smtpPORT
username:
from_secret: smtpUSERNAME
password:
from_secret: smtpPASSWORD
from:
from_secret: smtpFROM
subject: Mapping file
recipients:
from_secret: smtpTO
recipients_only: true
skip_verify: true
attachment: androidApp/build/outputs/mapping/githubConfigRelease/mapping.txt
trigger:
event:
- tag

View File

@ -0,0 +1,10 @@
version: '3'
services:
selfoss:
container_name: selfoss
image: rsprta/selfoss
network_mode: "host"
ports:
- "8888:8888"

View File

@ -0,0 +1,24 @@
name: Build
on:
workflow_call:
jobs:
BuildAndTest:
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: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Configure gradle...
run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=true" >> ~/.gradle/gradle.properties
- name: Build and test
run: ./gradlew build --stacktrace

View File

@ -0,0 +1,126 @@
name: Create tag
on:
push:
branches:
- release
workflow_dispatch:
jobs:
build:
uses: ./.gitea/workflows/common_build.yml
createTagAndChangelog:
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Config git
run: |
git config --global user.email aminecmi+giteadrone@pm.me
git config --global user.name giteadrone
- name: Creating the tag and generate changelog
run: |
git fetch --tags -p
PREV=$(git describe --tags --abbrev=0)
./build.sh --publish --from-ci
VER=$(git describe --tags --abbrev=0)
CHANGELOG=$(git log $PREV..HEAD --pretty="- %s")
echo "**$VER
$CHANGELOG
--------------------------------------------------------------------
$(cat CHANGELOG.md)" > CHANGELOG.md
git add CHANGELOG.md
touch ./fastlane/metadata/android/en\-US/changelogs/$VER.txt
echo "**$VER**
$CHANGELOG" > ./fastlane/metadata/android/en\-US/changelogs/$VER.txt
git add ./fastlane/metadata/android/en\-US/changelogs/$VER.txt
git commit -m "Changelog for $VER"
- name: Push changes
uses: appleboy/git-push-action@v1.0.0
with:
author_name: giteadrone
author_email: aminecmi+giteadrone@pm.me
remote: ${{ secrets.REMOTE_URL }}
followtags: true
ssh_key: ${{ secrets.PRIVATE_KEY }}
tags: true
branch: release
- name: copy file via ssh password
uses: appleboy/scp-action@v0.1.7
with:
host: amine-bouabdallaoui.fr
username: ubuntu
key: ${{ secrets.PRIVATE_KEY }}
source: "version.txt"
target: "/home/ubuntu/"
- name: deploy version file
uses: appleboy/ssh-action@v1.2.0
with:
host: amine-bouabdallaoui.fr
username: ubuntu
key: ${{ secrets.PRIVATE_KEY }}
script: cd /home/ubuntu && sudo rm -rf /var/www/amine/version.txt && sudo chown www-data:www-data ./version.txt && sudo mv version.txt /var/www/amine/
release:
runs-on: ubuntu-latest
needs: createTagAndChangelog
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
id: version
run: |
git fetch --tags -p
PREV=$(git describe --tags --abbrev=0)
echo $PREV
echo "VERSION=$PREV" >> $GITHUB_OUTPUT
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Configure gradle...
run: mkdir -p ~/.gradle && echo "org.gradle.daemon=false\nignoreGitVersion=false\nsystemProp.org.gradle.internal.http.connectionTimeout=180000\nsystemProp.org.gradle.internal.http.socketTimeout=180000" >> ~/.gradle/gradle.properties
- name: setup go
uses: https://github.com/actions/setup-go@v4
with:
go-version: '>=1.20.1'
- name: Generate APK
run: ./gradlew :androidApp:assembleGithubConfigRelease
- name: Get Key
run: wget ${{ secrets.KEY_URL }}
- name: Zippalign
run: |
sdkmanager "build-tools;31.0.0"
ls $ANDROID_HOME/build-tools
$ANDROID_HOME/build-tools/31.0.0/zipalign -f -v 4 androidApp/build/outputs/apk/githubConfig/release/androidApp-githubConfig-release-unsigned.apk androidApp/build/outputs/apk/githubConfig/release/android-prod-released-ziped.apk
- name: Sigh
run: $ANDROID_HOME/build-tools/31.0.0/apksigner sign -v --out signed.apk --ks ./key --ks-key-alias ${{ secrets.KEY_ALIAS }} --ks-pass pass:${{ secrets.KEYSTORE_PASSWORD }} --v1-signing-enabled true --v2-signing-enabled true androidApp/build/outputs/apk/githubConfig/release/android-prod-released-ziped.apk
- name: Verify
run: $ANDROID_HOME/build-tools/31.0.0/apksigner verify signed.apk
- name: Release
uses: https://gitea.com/actions/gitea-release-action@main
with:
files: signed.apk
token: ${{ secrets.API_KEY }}
tag_name: ${{ steps.version.outputs.VERSION }}
name: ${{ steps.version.outputs.VERSION }}
- name: Send mail
uses: https://github.com/dawidd6/action-send-mail@v4
with:
connection_url: ${{ secrets.MAIL_CONNECTION }}
to: ${{ secrets.MAIL_TO }}
from: ${{ secrets.MAIL_FROM }}
subject: Mapping file
priority: high
convert_markdown: true
body: Nouveau fichier de mapping pour la version ${{ steps.version.outputs.VERSION }}
attachments: androidApp/build/outputs/mapping/githubConfigRelease/mapping.txt

View File

@ -0,0 +1,26 @@
name: Check PR code
on:
pull_request:
branches:
- master
jobs:
Lint:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Install klint
run: curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.0.0/ktlint && chmod a+x ktlint && mv ktlint /usr/local/bin/
- name: Install detekt
run: curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.1/detekt-cli-1.23.1.zip && unzip detekt-cli-1.23.1.zip
- name: Linting...
run: ktlint 'shared/**/*.kt' 'androidApp/**/*.kt' '!shared/build' || true
- name: Detecting...
run: ./detekt-cli-1.23.1/bin/detekt-cli --all-rules --excludes '**/shared/build/**/*.kt' || true
build:
uses: ./.gitea/workflows/common_build.yml

View File

@ -0,0 +1,9 @@
name: Check master code
on:
push:
branches:
- master
jobs:
build:
uses: ./.gitea/workflows/common_build.yml

View File

@ -0,0 +1,44 @@
name: Check master code
on:
push:
branches:
- master
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- 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'
cache: gradle
- 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: coverage
run: |
./gradlew :koverHtmlReport
- uses: actions/upload-artifact@v3
with:
name: coverage
path: build/reports/kover/html
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

@ -10,7 +10,7 @@ Please read the guidelines before contributing, and follow them (or try to) when
There are many ways to contribute to this project, you could [translate the app](https://crowdin.com/project/readerforselfoss), report bugs, request missing features, suggest enhancements and changes to existing ones. You also can improve the README with useful tips that could help the other users. There are many ways to contribute to this project, you could [translate the app](https://crowdin.com/project/readerforselfoss), report bugs, request missing features, suggest enhancements and changes to existing ones. You also can improve the README with useful tips that could help the other users.
You can fork the repository, and [help me solve some issues](https://gitea.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform/issues?q=is%3Aissue+is%3Aopen+label%3A%22Up+For+Grabs%22) or [develop new things](https://gitea.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform/issues) You can fork the repository, and [help me solve some issues](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform/issues?q=is%3Aissue+is%3Aopen+label%3A%22Up+For+Grabs%22) or [develop new things](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform/issues)
### What I can't help you with. ### What I can't help you with.

5
.gitignore vendored
View File

@ -320,4 +320,7 @@ fabric.properties
# End of https://www.toptal.com/developers/gitignore/api/gradle,kotlin,androidstudio,android,xcode,swift # End of https://www.toptal.com/developers/gitignore/api/gradle,kotlin,androidstudio,android,xcode,swift
crowdin.properties crowdin.properties
.kotlin/
build-cache/

View File

@ -1,3 +1,110 @@
**v124123421
- fix: Trying to fix the serialization issue.
- Changelog for v124113311
--------------------------------------------------------------------
**v124113311
- chore: update versions. (#165)
- chore: fastlane changelog.
- chore: fastlane fixes.
- Changelog for v124113301
--------------------------------------------------------------------
**v124113301**
- chore: Gitea Action
- Merge pull request 'chore: Gitea Action' (#164) from runner into master
- chore: Gitea Action
- chore: Readme update.
--------------------------------------------------------------------
**v124041081**
- chore: comment.
- fix: Last time fixing the parsing date hack before moving it to os version.
- Changelog for v124030731 [CI SKIP]
--------------------------------------------------------------------
**v124030731**
- fix: Basic auth and password can have non whitspace characters. Fixes 142.
- Changelog for v124020451 [CI SKIP]
--------------------------------------------------------------------
**v124020451**
- fix: Fixed handling of position in card adapter.
- Changelog for v124010301 [CI SKIP]
--------------------------------------------------------------------
**v124010301**
- fix: This may fix the oom errors.
- Changelog for v124010191 [CI SKIP]
--------------------------------------------------------------------
**v124010191**
- fix: moving listeners.
- chore: removed a useless log.
- Changelog for v124010032 [CI SKIP]
--------------------------------------------------------------------
**v124010032**
- fix: Another date format thing.
- Changelog for v124010031 [CI SKIP]
--------------------------------------------------------------------
**v124010031**
- fix: Checking if selfoss instance.
- fix: handle three characters lenght hexcode colors.
- Changelog for v123113311 [CI SKIP]
--------------------------------------------------------------------
**v123113311**
- chore: Source tracker url in the menu.
- fix: Handle kodein proguard rules.
- Changelog for v123102961 [CI SKIP]
--------------------------------------------------------------------
**v123102961**
- chore: domain changes.
- Changelog for v123102852 [CI SKIP]
--------------------------------------------------------------------
**v123102852**
- chore: lint cleaning.
- Changelog for v123102841 [CI SKIP]
--------------------------------------------------------------------
**v123102841**
- chore: cleaning ci steps and upgrading dependencies.
- feat: Self signed ssl support.
- Changelog for v123061811 [CI SKIP]
--------------------------------------------------------------------
**v123061811** **v123061811**
- feat: Added confirmation dialog for disconnect item menu. - feat: Added confirmation dialog for disconnect item menu.

View File

@ -1,4 +1,4 @@
# ReaderForSelfoss-multiplatform [![Build Status](https://build.amine-louveau.fr/api/badges/Louvorg/ReaderForSelfoss-multiplatform/status.svg)](https://build.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform) # ReaderForSelfoss-multiplatform [![Build Status](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform/actions/workflows/on_push.yml/badge.svg)](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform/actions?workflow=on_push.yml&actor=0&status=0)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/readerforselfoss/localized.svg)](https://crowdin.com/project/readerforselfoss) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/readerforselfoss/localized.svg)](https://crowdin.com/project/readerforselfoss)
@ -10,10 +10,6 @@ If you are a user, you can still create new issues. I'll fix them when I can.
<a href="https://f-droid.org/packages/bou.amine.apps.readerforselfossv2.android"><img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="100"></a> <a href="https://f-droid.org/packages/bou.amine.apps.readerforselfossv2.android"><img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="100"></a>
## Screen captures
<img src="res//fr-card.png?raw=true" alt="card view" width="400"/> <img src="res//fr-list.png?raw=true" alt="list view" width="400"/>
## Like my app ? ## Like my app ?
<a href="https://www.buymeacoffee.com/aminecmi" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/lato-orange.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a> <a href="https://www.buymeacoffee.com/aminecmi" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/lato-orange.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a>
@ -22,15 +18,15 @@ If you are a user, you can still create new issues. I'll fix them when I can.
1. **You'll have to have a Selfoss instance running.** You'll find everything you need to install it [here](https://selfoss.aditu.de/). 1. **You'll have to have a Selfoss instance running.** You'll find everything you need to install it [here](https://selfoss.aditu.de/).
2. Check the [Contribution guide](https://gitea.amine-louveau.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/.github/CONTRIBUTING.md). 2. Check the [Contribution guide](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/.github/CONTRIBUTING.md).
3. Build the project by following [these steps](https://gitea.amine-louveau.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/.github/CONTRIBUTING.md#build-the-project) (you should have read them after the contribution guide) 3. Build the project by following [these steps](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/.github/CONTRIBUTING.md#build-the-project) (you should have read them after the contribution guide)
## Useful links ## Useful links
- [Check what changed](https://gitea.amine-louveau.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/CHANGELOG.md) - [Check what changed](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderforSelfoss-multiplatform/src/branch/master/CHANGELOG.md)
- [See what I'm doing](https://gitea.amine-louveau.fr/Louvorg/ReaderforSelfoss-multiplatform/projects/1) - [See what I'm doing](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderforSelfoss-multiplatform/projects/1)
- [Create an issue, or request a new feature](https://gitea.amine-louveau.fr/Louvorg/ReaderforSelfoss-multiplatform/issues) - [Create an issue, or request a new feature](https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderforSelfoss-multiplatform/issues)
- [Help translation the app](https://crowdin.com/project/readerforselfoss) - [Help translation the app](https://crowdin.com/project/readerforselfoss)
## Contributors (V1) (Alphabetical order) ❤️ ## Contributors (V1) (Alphabetical order) ❤️

View File

@ -1 +1,2 @@
/build /build
.kotlin/

View File

@ -84,6 +84,7 @@ android {
// tests // tests
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
} }
packaging { packaging {
resources { resources {
@ -107,6 +108,13 @@ android {
} }
} }
namespace = "bou.amine.apps.readerforselfossv2.android" namespace = "bou.amine.apps.readerforselfossv2.android"
testOptions {
animationsDisabled = true
execution = "ANDROIDX_TEST_ORCHESTRATOR"
unitTests {
isIncludeAndroidResources = true
}
}
} }
@ -184,9 +192,18 @@ dependencies {
testImplementation("io.mockk:mockk:1.12.0") testImplementation("io.mockk:mockk:1.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
implementation("androidx.test.espresso:espresso-idling-resource:3.6.1")
androidTestImplementation("androidx.test.ext:junit-ktx:1.2.1")
androidTestUtil("androidx.test:orchestrator:1.5.1")
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core-ktx:1.6.1")
implementation("ch.acra:acra-http:$acraVersion") implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-toast:$acraVersion") implementation("ch.acra:acra-toast:$acraVersion")
} }
tasks.withType<Test> { tasks.withType<Test> {

View File

@ -86,3 +86,12 @@
-dontwarn io.mockk.** -dontwarn io.mockk.**
-keep class io.mockk.** { *; } -keep class io.mockk.** { *; }
# Kodein
-keep, allowobfuscation, allowoptimization class org.kodein.type.TypeReference
-keep, allowobfuscation, allowoptimization class org.kodein.type.JVMAbstractTypeToken$Companion$WrappingTest
-keep, allowobfuscation, allowoptimization class * extends org.kodein.type.TypeReference
-keep, allowobfuscation, allowoptimization class * extends org.kodein.type.JVMAbstractTypeToken$Companion$WrappingTest

View File

@ -0,0 +1,90 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.annotation.ArrayRes
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.CoreMatchers.allOf
fun performLogin(someUrl: String? = null) {
onView(withId(R.id.urlView)).perform(click()).perform(
typeTextIntoFocusedView(
if (!someUrl.isNullOrEmpty()) someUrl else "http://10.0.2.2:8888"
)
)
onView(withId(R.id.signInButton)).perform(click())
Thread.sleep(10000)
}
fun loginAndInitHome() {
performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
onView(withText("OK")).perform(click())
}
fun changeAndCancelSetting(oldValue: String, newValue: String, openSettingItem: () -> Unit) {
openSettingItem()
onView(
withId(android.R.id.edit)
).perform(replaceText(newValue))
onView(
withId(android.R.id.button2)
).perform(click())
openSettingItem()
onView(
withId(android.R.id.edit)
).check(matches(withText(oldValue)))
onView(
withText(newValue)
).check(doesNotExist())
onView(
withId(android.R.id.button2)
).perform(click())
}
fun changeAndSaveSetting(oldValue: String, newValue: String, openSettingItem: () -> Unit) {
openSettingItem()
onView(
withId(android.R.id.edit)
).perform(replaceText(newValue))
onView(
withId(android.R.id.button1)
).perform(click())
openSettingItem()
onView(
withId(android.R.id.edit)
).check(matches(withText(newValue)))
if (oldValue.isNotEmpty()) {
onView(
withText(oldValue)
).check(doesNotExist())
}
onView(
withId(android.R.id.button2)
).perform(click())
}
fun testPreferencesFromArray(
context: Context,
@ArrayRes arrayRes: Int,
openSettingItem: () -> Unit
) {
openSettingItem()
context.resources.getStringArray(arrayRes).forEach { res ->
onView(withText(res)).check(matches(allOf(isDisplayed(), isNotChecked())))
onView(withText(res)).perform(click())
onView(withText(res)).check(doesNotExist())
openSettingItem()
onView(withText(res)).check(matches(allOf(isDisplayed(), isChecked())))
}
}

View File

@ -0,0 +1,137 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isFocused
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.isSelected
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class HomeActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Before
fun init() {
loginAndInitHome()
}
@Test
fun testMenu() {
onView(withId(R.id.action_search)).check(matches(isDisplayed())).check(
matches(
isClickable()
)
)
onView(withId(R.id.action_filter)).check(matches(isDisplayed())).check(
matches(
isClickable()
)
)
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.readAll)).check(matches(isDisplayed()))
onView(withText(R.string.menu_home_sources)).check(matches(isDisplayed()))
onView(withText(R.string.title_activity_settings)).check(matches(isDisplayed()))
onView(withText(R.string.menu_home_refresh)).check(matches(isDisplayed()))
onView(withText(R.string.issue_tracker_link)).check(matches(isDisplayed()))
onView(withText(R.string.action_disconnect)).check(matches(isDisplayed()))
}
@Test
fun testMenuActions() {
onView(withId(R.id.action_search)).perform(click())
onView(
withId(R.id.search_src_text)
).check(matches(isFocused()))
onView(isRoot()).perform(ViewActions.pressBack())
onView(withId(R.id.action_filter)).perform(click())
onView(
withText(R.string.filter_item_sources)
).check(matches(isDisplayed()))
onView(
withText(R.string.filter_item_tags)
).check(matches(isDisplayed()))
onView(
withId(R.id.floatingActionButton2)
).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.readAll)).perform(click())
onView(withText(R.string.markall_dialog_message)).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.menu_home_sources)).perform(click())
onView(withId(R.id.fab)).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_general)).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.menu_home_refresh)).perform(click())
onView(withText(R.string.refresh_dialog_message)).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
/*onView(withText(R.string.issue_tracker_link)).perform(click())
onView(withText(R.string.markall_dialog_message)).check(matches(isDisplayed()))
onView(isRoot()).perform(ViewActions.pressBack())
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)*/
onView(withText(R.string.action_disconnect)).perform(click())
onView(withText(R.string.confirm_disconnect_title)).check(matches(isDisplayed()))
}
@Test
fun testEmptyView() {
onView(withId(R.id.emptyText)).check(matches(isDisplayed()))
onView(
hasBottombarItemText(R.string.tab_new)
).check(matches(isDisplayed())).check(matches(isSelected()))
onView(
hasBottombarItemText(R.string.tab_read)
).check(matches(isDisplayed())).check(matches(not(isSelected())))
onView(
hasBottombarItemText(R.string.tab_favs)
).check(matches(isDisplayed())).check(matches(not(isSelected())))
}
}

View File

@ -0,0 +1,83 @@
package bou.amine.apps.readerforselfossv2.android
import android.app.Activity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
private fun getActivity(): Activity? {
var activity: Activity? = null
activityRule.scenario.onActivity {
activity = it
}
return activity
}
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance()
.register(CountingIdlingResourceSingleton.countingIdlingResource)
}
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance()
.unregister(CountingIdlingResourceSingleton.countingIdlingResource)
}
@Test
fun viewIsInitialized() {
onView(withId(R.id.urlView)).check(matches(isDisplayed()))
onView(withId(R.id.selfSigned)).check(matches(isDisplayed())).check(matches(isNotChecked()))
.check(
matches(isClickable())
)
onView(withId(R.id.withLogin)).check(matches(isDisplayed()))
.check(matches(isNotChecked())).check(
matches(isClickable())
)
}
@Test
fun urlError() {
performLogin("172.17.0.1:8888")
onView(withId(R.id.urlView)).perform(click())
onView(withId(R.id.urlView)).check(matches(withError(R.string.wrong_infos)))
}
@Test
fun multiError() {
onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click())
onView(withId(R.id.signInButton)).perform(click())
onView(withText(R.string.warning_wrong_url)).check(matches(isDisplayed()))
}
@Test
fun connect() {
performLogin()
onView(withText(R.string.gdpr_dialog_title)).check(matches(isDisplayed()))
}
}

View File

@ -0,0 +1,172 @@
package bou.amine.apps.readerforselfossv2.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isFocused
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SettingsActivityGeneralTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Before
fun init() {
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext()
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_general)).perform(click())
}
@Test
fun testGeneral() {
onView(withText(R.string.pref_api_items_number_title)).check(matches(isDisplayed()))
onView(
withSettingsCheckboxWidget(R.string.pref_general_infinite_loading_title)
).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withText(R.string.pref_general_category_links)).check(matches(isDisplayed()))
onView(withSettingsCheckboxWidget(R.string.pref_article_viewer_title)).check(
matches(
allOf(
isDisplayed(), isChecked()
)
)
)
onView(withSettingsCheckboxWidget(R.string.reader_static_bar_title)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxFrame(R.string.reader_static_bar_title)).check(
matches(
isEnabled()
)
)
onView(withText(R.string.pref_general_category_displaying)).check(matches(isDisplayed()))
onView(withSettingsCheckboxWidget(R.string.pref_switch_card_view_title)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxWidget(R.string.card_height_title)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxFrame(R.string.card_height_title)).check(
matches(
not(isEnabled())
)
)
onView(withSettingsCheckboxWidget(R.string.switch_unread_count_title)).check(
matches(
allOf(
isDisplayed(), isChecked()
)
)
)
onView(withSettingsCheckboxWidget(R.string.display_all_counts_title)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
}
@Test
fun testGeneralActionsNumberItems() {
onView(withText(R.string.pref_api_items_number_title)).perform(click())
onView(withId(android.R.id.edit)).check(matches(isFocused()))
// Value check
onView(
withId(android.R.id.edit)
).perform(replaceText("AVC"))
.check(matches(withText("")))
// TODO: should check message error. Not working for api level 30+
onView(
withId(android.R.id.edit)
).perform(replaceText("-1"))
.check(matches(withText("")))
// TODO: should check message error. Not working for api level 30+
onView(
withId(android.R.id.edit)
).perform(replaceText("300"))
.check(matches(withText("")))
onView(
withId(android.R.id.edit)
).perform(typeTextIntoFocusedView("300"))
.check(matches(withText("30")))
onView(
withId(android.R.id.edit)
).perform(replaceText("10"))
.check(matches(withText("10")))
onView(isRoot()).perform(ViewActions.pressBack())
// Value saving
changeAndCancelSetting("20", "10") {
onView(withText(R.string.pref_api_items_number_title)).perform(click())
}
changeAndSaveSetting("20", "10") {
onView(withText(R.string.pref_api_items_number_title)).perform(click())
}
}
@Test
fun testGeneralActionsCheckboxes() {
// article viewer settings
onView(withSettingsCheckboxFrame(R.string.reader_static_bar_title)).check(
matches(
isEnabled()
)
)
onView(withSettingsCheckboxWidget(R.string.pref_article_viewer_title)).perform(click())
onView(withSettingsCheckboxFrame(R.string.reader_static_bar_title)).check(
matches(
not(isEnabled())
)
)
onView(withSettingsCheckboxFrame(R.string.card_height_title)).check(matches(not(isEnabled())))
onView(withSettingsCheckboxWidget(R.string.pref_switch_card_view_title)).perform(click())
onView(withSettingsCheckboxFrame(R.string.card_height_title)).check(matches(isEnabled()))
}
}

View File

@ -0,0 +1,169 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SettingsActivityOfflineTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
lateinit var context: Context
@Before
fun init() {
activityRule.scenario.onActivity { activity ->
context = activity.window.context
}
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext()
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_offline)).perform(click())
}
@Test
fun testOffline() {
onView(withSettingsCheckboxWidget(R.string.pref_switch_periodic_refresh)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxWidget(R.string.pref_switch_items_caching)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_items_caching)).check(
matches(
isEnabled()
)
)
onView(withText(R.string.pref_periodic_refresh_minutes_title)).check(
matches(
allOf(isNotEnabled(), isDisplayed())
)
)
onView(withSettingsCheckboxWidget(R.string.pref_switch_refresh_when_charging)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_refresh_when_charging)).check(
matches(
isNotEnabled()
)
)
onView(withSettingsCheckboxWidget(R.string.pref_switch_notify_new_items)).check(
matches(
allOf(
isDisplayed(), not(isChecked())
)
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_notify_new_items)).check(
matches(
isNotEnabled()
)
)
onView(withSettingsCheckboxWidget(R.string.pref_switch_update_sources)).check(
matches(
allOf(
isDisplayed(), isChecked()
)
)
)
}
@Test
fun testOfflineActions() {
onView(withText(R.string.pref_switch_items_caching_off)).check(matches(isDisplayed()))
onView(withText(R.string.pref_switch_items_caching)).perform(click())
onView(withText(R.string.pref_switch_items_caching_on)).check(matches(isDisplayed()))
onView(withSettingsCheckboxFrame(R.string.pref_switch_items_caching)).check(
matches(
isEnabled()
)
)
onView(withText(R.string.pref_periodic_refresh_minutes_title)).check(
matches(
isNotEnabled()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_refresh_when_charging)).check(
matches(
isNotEnabled()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_notify_new_items)).check(
matches(
isNotEnabled()
)
)
onView(withText(R.string.pref_switch_periodic_refresh_off)).check(
matches(
isDisplayed()
)
)
onView(withSettingsCheckboxWidget(R.string.pref_switch_periodic_refresh)).perform(click())
onView(withText(R.string.pref_switch_periodic_refresh_on)).check(
matches(
isDisplayed()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_periodic_refresh_minutes_title)).check(
matches(
isEnabled()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_refresh_when_charging)).check(
matches(
isEnabled()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_notify_new_items)).check(
matches(
isEnabled()
)
)
changeAndCancelSetting("360", "123") {
onView(withText(R.string.pref_periodic_refresh_minutes_title)).perform(click())
}
changeAndSaveSetting("360", "123") {
onView(withText(R.string.pref_periodic_refresh_minutes_title)).perform(click())
}
onView(withSettingsCheckboxFrame(R.string.pref_switch_refresh_when_charging)).perform(click())
onView(withSettingsCheckboxFrame(R.string.pref_switch_notify_new_items)).perform(click())
onView(withSettingsCheckboxWidget(R.string.pref_switch_update_sources)).perform(click())
}
}

View File

@ -0,0 +1,86 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SettingsActivityReaderTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
lateinit var context: Context
@Before
fun init() {
activityRule.scenario.onActivity { activity ->
context = activity.window.context
}
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext()
)
onView(withText(R.string.title_activity_settings)).perform(click())
onView(withText(R.string.pref_header_viewer)).perform(click())
}
@Test
fun testReader() {
onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).check(
matches(
allOf(
isDisplayed(), not(
isChecked()
)
)
)
)
onView(withText(R.string.pref_content_reader_font_size)).check(matches(isDisplayed()))
onView(withText(R.string.settings_reader_font)).check(matches(isDisplayed()))
}
@Test
fun testReaderActions() {
onView(withText(R.string.pref_switch_actions_pager_scroll_off)).check(
matches(
isDisplayed()
)
)
onView(withSettingsCheckboxFrame(R.string.pref_switch_actions_pager_scroll)).perform(click())
onView(withText(R.string.pref_switch_actions_pager_scroll_on)).check(
matches(
isDisplayed()
)
)
onView(withText(R.string.pref_content_reader_font_size)).perform(click())
changeAndCancelSetting("16", "10") {
onView(withText(R.string.pref_content_reader_font_size)).perform(click())
}
changeAndSaveSetting("16", "10") {
onView(withText(R.string.pref_content_reader_font_size)).perform(click())
}
testPreferencesFromArray(context, R.array.preloaded_fonts_values) {
onView(withText(R.string.settings_reader_font)).perform(click())
}
}
}

View File

@ -0,0 +1,104 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isSelected
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SettingsActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
lateinit var context: Context
@Before
fun init() {
activityRule.scenario.onActivity { activity ->
context = activity.window.context
}
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.title_activity_settings)).perform(click())
}
@Test
fun testAllSettings() {
onView(withText(R.string.pref_header_general)).check(matches(isDisplayed()))
onView(withText(R.string.pref_header_viewer)).check(matches(isDisplayed()))
onView(withText(R.string.pref_header_offline)).check(matches(isDisplayed()))
onView(withText(R.string.pref_header_theme)).check(matches(isDisplayed()))
onView(withText(R.string.pref_header_links)).check(matches(isDisplayed()))
onView(withText(R.string.pref_switch_disable_acra)).check(
matches(
allOf(
isDisplayed(),
not(isSelected())
)
)
)
onView(withText(R.string.action_about)).check(matches(isDisplayed()))
}
@Test
fun testThemes() {
testPreferencesFromArray(context, R.array.ModeTitles) {
onView(withText(R.string.pref_header_theme)).perform(click())
}
}
@Test
fun testExperimentail() {
onView(withText(R.string.pref_header_experimental)).perform(click())
changeAndCancelSetting("", "10") {
onView(withText(R.string.pref_api_timeout)).perform(click())
}
changeAndSaveSetting("", "10") {
onView(withText(R.string.pref_api_timeout)).perform(click())
}
}
@Test
fun testBugReports() {
onView(withText(R.string.pref_switch_disable_acra)).perform(click())
}
@Test
fun testLinks() {
onView(withText(R.string.pref_header_links)).perform(click())
onView(withText(R.string.issue_tracker_link)).check(matches(isDisplayed()))
onView(withText(R.string.issue_tracker_summary)).check(matches(isDisplayed()))
onView(withText(R.string.source_code)).check(matches(isDisplayed()))
onView(withText(R.string.translation)).check(matches(isDisplayed()))
}
@Test
fun testAbout() {
onView(withText(R.string.action_about)).perform(click())
onView(withText("ACRA")).check(matches(isDisplayed()))
}
}

View File

@ -0,0 +1,65 @@
package bou.amine.apps.readerforselfossv2.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollCompletelyTo
import androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@LargeTest
class SourcesActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
lateinit var sourceName: String
@Before
fun init() {
sourceName = UUID.randomUUID().toString().substring(0, 15)
loginAndInitHome()
openActionBarOverflowOrOptionsMenu(
ApplicationProvider.getApplicationContext<Context>()
)
onView(withText(R.string.menu_home_sources))
.perform(click())
}
@Test
fun addSource() {
onView(withId(R.id.fab))
.perform(click())
onView(withId(R.id.nameInput))
.perform(click()).perform(typeTextIntoFocusedView(sourceName))
onView(withId(R.id.sourceUri))
.perform(click())
.perform(typeTextIntoFocusedView("https://lorem-rss.herokuapp.com/feed?unit=year&interval=1&length=10"))
onView(withId(R.id.tags))
.perform(click()).perform(typeTextIntoFocusedView("tag1,tag2,tag3"))
onView(withId(R.id.spoutsSpinner))
.perform(click())
onView(withText("RSS Feed"))
.perform(scrollCompletelyTo())
.perform(click())
onView(withId(R.id.saveBtn))
.perform(click())
onView(withText(sourceName)).check(matches(isDisplayed()))
}
}

View File

@ -0,0 +1,101 @@
package bou.amine.apps.readerforselfossv2.android
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.graphics.drawable.toBitmap
import androidx.test.espresso.Root
import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
import androidx.test.espresso.matcher.ViewMatchers.withChild
import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withResourceName
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
fun withError(@StringRes id: Int): TypeSafeMatcher<View?> {
return object : TypeSafeMatcher<View?>() {
override fun matchesSafely(view: View?): Boolean {
if (view == null) {
return false
}
val context = view.context
if (view !is EditText) {
return false
}
if (view.error == null) {
return false
}
return view.error.toString() == context.getString(id)
}
override fun describeTo(description: Description?) {
}
}
}
fun isPopupWindow(): Matcher<Root> {
return isPlatformPopup()
}
fun withDrawable(@DrawableRes id: Int) = object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("ImageView with drawable same as drawable with id $id")
}
override fun matchesSafely(view: View): Boolean {
val context = view.context
val expectedBitmap = context.getDrawable(id)!!.toBitmap()
try {
return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap)
} catch (e: Exception) {
return false
}
}
}
fun hasBottombarItemText(@StringRes id: Int): Matcher<View>? {
return allOf(
withResourceName("fixed_bottom_navigation_icon"),
withParent(
allOf(
withResourceName("fixed_bottom_navigation_icon_container"),
hasSibling(withText(id))
)
)
)
}
fun withSettingsCheckboxWidget(@StringRes id: Int): Matcher<View>? {
return allOf(
withId(android.R.id.switch_widget),
withParent(
withSettingsCheckboxFrame(id)
)
)
}
fun withSettingsCheckboxFrame(@StringRes id: Int): Matcher<View>? {
return allOf(
withId(android.R.id.widget_frame),
hasSibling(
allOf(
withClassName(Matchers.equalTo(RelativeLayout::class.java.name)),
withChild(
withText(id)
)
)
)
)
}

View File

@ -6,4 +6,4 @@ import org.acra.ktx.sendSilentlyWithAcra
fun Throwable.sendSilentlyWithAcraWithName(name: String) { fun Throwable.sendSilentlyWithAcraWithName(name: String) {
ACRA.errorReporter.putCustomData("error_source", name) ACRA.errorReporter.putCustomData("error_source", name)
this.sendSilentlyWithAcra() this.sendSilentlyWithAcra()
} }

View File

@ -1,6 +1,7 @@
package bou.amine.apps.readerforselfossv2.android package bou.amine.apps.readerforselfossv2.android
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@ -12,7 +13,11 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.* import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
@ -24,6 +29,7 @@ import bou.amine.apps.readerforselfossv2.android.background.LoadingWorker
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityHomeBinding import bou.amine.apps.readerforselfossv2.android.databinding.ActivityHomeBinding
import bou.amine.apps.readerforselfossv2.android.fragments.FilterSheetFragment import bou.amine.apps.readerforselfossv2.android.fragments.FilterSheetFragment
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import bou.amine.apps.readerforselfossv2.android.utils.bottombar.maybeShow import bou.amine.apps.readerforselfossv2.android.utils.bottombar.maybeShow
import bou.amine.apps.readerforselfossv2.android.utils.bottombar.removeBadge import bou.amine.apps.readerforselfossv2.android.utils.bottombar.removeBadge
import bou.amine.apps.readerforselfossv2.model.SelfossModel import bou.amine.apps.readerforselfossv2.model.SelfossModel
@ -43,9 +49,7 @@ import org.kodein.di.instance
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAware { class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAware {
private var items: ArrayList<SelfossModel.Item> = ArrayList() private var items: ArrayList<SelfossModel.Item> = ArrayList()
private var elementsShown: ItemType = ItemType.UNREAD private var elementsShown: ItemType = ItemType.UNREAD
@ -59,29 +63,30 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
private lateinit var recyclerViewScrollListener: RecyclerView.OnScrollListener private lateinit var recyclerViewScrollListener: RecyclerView.OnScrollListener
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private var recyclerAdapter: RecyclerView.Adapter<*>? = null private var recyclerAdapter: ItemsAdapter<out RecyclerView.ViewHolder>? = null
private var fromTabShortcut: Boolean = false private var fromTabShortcut: Boolean = false
private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private val settingsLauncher =
appSettingsService.refreshUserSettings() registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
} appSettingsService.refreshUserSettings()
}
override val di by closestDI() override val 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()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
val view = binding.root val view = binding.root
fromTabShortcut = intent.getIntExtra("shortcutTab", -1) != -1 fromTabShortcut = intent.getIntExtra("shortcutTab", -1) != -1
repository.offlineOverride = intent.getBooleanExtra("startOffline", false) repository.offlineOverride = intent.getBooleanExtra("startOffline", false)
if (fromTabShortcut) { if (fromTabShortcut) {
elementsShown = ItemType.fromInt(intent.getIntExtra("shortcutTab", ItemType.UNREAD.position)) elementsShown =
ItemType.fromInt(intent.getIntExtra("shortcutTab", ItemType.UNREAD.position))
} }
setContentView(view) setContentView(view)
@ -92,10 +97,11 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
handleSwipeRefreshLayout() handleSwipeRefreshLayout()
if (appSettingsService.isItemCachingEnabled()) { if (appSettingsService.isItemCachingEnabled()) {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
repository.tryToCacheItemsAndGetNewOnes() repository.tryToCacheItemsAndGetNewOnes()
CountingIdlingResourceSingleton.decrement()
} }
} }
} }
@ -104,48 +110,54 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
binding.swipeRefreshLayout.setColorSchemeResources( binding.swipeRefreshLayout.setColorSchemeResources(
R.color.refresh_progress_1, R.color.refresh_progress_1,
R.color.refresh_progress_2, R.color.refresh_progress_2,
R.color.refresh_progress_3 R.color.refresh_progress_3,
) )
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
repository.offlineOverride = false repository.offlineOverride = false
lastFetchDone = false lastFetchDone = false
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
getElementsAccordingToTab() getElementsAccordingToTab()
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
CountingIdlingResourceSingleton.decrement()
} }
} }
val swipeDirs = if (appSettingsService.getPublicAccess()) { val swipeDirs =
0 if (appSettingsService.getPublicAccess()) {
} else { 0
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT } else {
} ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
}
val simpleItemTouchCallback = val simpleItemTouchCallback =
object : ItemTouchHelper.SimpleCallback( object : ItemTouchHelper.SimpleCallback(
0, 0,
swipeDirs swipeDirs,
) { ) {
override fun getSwipeDirs( override fun getSwipeDirs(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder viewHolder: RecyclerView.ViewHolder,
): Int = ): Int =
if (elementsShown == ItemType.STARRED) { if (elementsShown == ItemType.STARRED) {
0 0
} else { } else {
super.getSwipeDirs( super.getSwipeDirs(
recyclerView, recyclerView,
viewHolder viewHolder,
) )
} }
override fun onMove( override fun onMove(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder,
): Boolean = false ): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { override fun onSwiped(
viewHolder: RecyclerView.ViewHolder,
swipeDir: Int,
) {
val position = viewHolder.bindingAdapterPosition val position = viewHolder.bindingAdapterPosition
val i = items.elementAtOrNull(position) val i = items.elementAtOrNull(position)
@ -162,7 +174,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
Toast.makeText( Toast.makeText(
this@HomeActivity, this@HomeActivity,
"Found null when swiping at positon $position.", "Found null when swiping at positon $position.",
Toast.LENGTH_LONG Toast.LENGTH_LONG,
).show() ).show()
} }
} }
@ -171,7 +183,10 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
ItemTouchHelper(simpleItemTouchCallback).attachToRecyclerView(binding.recyclerView) ItemTouchHelper(simpleItemTouchCallback).attachToRecyclerView(binding.recyclerView)
} }
private fun updateBottomBarBadgeCount(badge: TextBadgeItem, count: Int) { private fun updateBottomBarBadgeCount(
badge: TextBadgeItem,
count: Int,
) {
if (count > 0) { if (count > 0) {
badge badge
.setText(count.toString()) .setText(count.toString())
@ -182,16 +197,18 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
} }
private fun handleBottomBar() { private fun handleBottomBar() {
tabNewBadge =
tabNewBadge = TextBadgeItem() TextBadgeItem()
.setText("") .setText("")
.setHideOnSelect(false).hide(false) .setHideOnSelect(false).hide(false)
tabArchiveBadge = TextBadgeItem() tabArchiveBadge =
.setText("") TextBadgeItem()
.setHideOnSelect(false).hide(false) .setText("")
tabStarredBadge = TextBadgeItem() .setHideOnSelect(false).hide(false)
.setText("") tabStarredBadge =
.setHideOnSelect(false).hide(false) TextBadgeItem()
.setText("")
.setHideOnSelect(false).hide(false)
if (appSettingsService.isDisplayUnreadCountEnabled()) { if (appSettingsService.isDisplayUnreadCountEnabled()) {
lifecycleScope.launch { lifecycleScope.launch {
@ -218,19 +235,19 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
val tabNew = val tabNew =
BottomNavigationItem( BottomNavigationItem(
R.drawable.ic_tab_fiber_new_black_24dp, R.drawable.ic_tab_fiber_new_black_24dp,
getString(R.string.tab_new) getString(R.string.tab_new),
) )
.setBadgeItem(tabNewBadge) .setBadgeItem(tabNewBadge)
val tabArchive = val tabArchive =
BottomNavigationItem( BottomNavigationItem(
R.drawable.ic_tab_archive_black_24dp, R.drawable.ic_tab_archive_black_24dp,
getString(R.string.tab_read) getString(R.string.tab_read),
) )
.setBadgeItem(tabArchiveBadge) .setBadgeItem(tabArchiveBadge)
val tabStarred = val tabStarred =
BottomNavigationItem( BottomNavigationItem(
R.drawable.ic_tab_favorite_black_24dp, R.drawable.ic_tab_favorite_black_24dp,
getString(R.string.tab_favs) getString(R.string.tab_favs),
).setActiveColorResource(R.color.pink) ).setActiveColorResource(R.color.pink)
.setBadgeItem(tabStarredBadge) .setBadgeItem(tabStarredBadge)
@ -263,15 +280,15 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
handleGDPRDialog(appSettingsService.settings.getBoolean("GDPR_shown", false)) handleGDPRDialog(appSettingsService.settings.getBoolean("GDPR_shown", false))
handleRecurringTask() handleRecurringTask()
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
repository.handleDBActions() repository.handleDBActions()
CountingIdlingResourceSingleton.decrement()
} }
getElementsAccordingToTab() getElementsAccordingToTab()
} }
private fun handleGDPRDialog(GDPRShown: Boolean) { private fun handleGDPRDialog(GDPRShown: Boolean) {
val messageDigest: MessageDigest = MessageDigest.getInstance("SHA-256") val messageDigest: MessageDigest = MessageDigest.getInstance("SHA-256")
messageDigest.update(appSettingsService.getBaseUrl().toByteArray()) messageDigest.update(appSettingsService.getBaseUrl().toByteArray())
@ -281,7 +298,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
alertDialog.setMessage(getString(R.string.gdpr_dialog_message)) alertDialog.setMessage(getString(R.string.gdpr_dialog_message))
alertDialog.setButton( alertDialog.setButton(
AlertDialog.BUTTON_NEUTRAL, AlertDialog.BUTTON_NEUTRAL,
"OK" "OK",
) { dialog, _ -> ) { dialog, _ ->
appSettingsService.settings.putBoolean("GDPR_shown", true) appSettingsService.settings.putBoolean("GDPR_shown", true)
dialog.dismiss() dialog.dismiss()
@ -298,37 +315,43 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
when (currentManager) { when (currentManager) {
is StaggeredGridLayoutManager -> is StaggeredGridLayoutManager ->
if (!appSettingsService.isCardViewEnabled()) { if (!appSettingsService.isCardViewEnabled()) {
layoutManager = GridLayoutManager( layoutManager =
this, GridLayoutManager(
calculateNoOfColumns() this,
) calculateNoOfColumns(),
)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
} }
is GridLayoutManager -> is GridLayoutManager ->
if (appSettingsService.isCardViewEnabled()) { if (appSettingsService.isCardViewEnabled()) {
layoutManager = StaggeredGridLayoutManager( layoutManager =
calculateNoOfColumns(), StaggeredGridLayoutManager(
StaggeredGridLayoutManager.VERTICAL calculateNoOfColumns(),
) StaggeredGridLayoutManager.VERTICAL,
)
layoutManager.gapStrategy = layoutManager.gapStrategy =
StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
} }
else -> else ->
if (currentManager == null) { if (currentManager == null) {
if (!appSettingsService.isCardViewEnabled()) { if (!appSettingsService.isCardViewEnabled()) {
layoutManager = GridLayoutManager( layoutManager =
this, GridLayoutManager(
calculateNoOfColumns() this,
) calculateNoOfColumns(),
)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
} else { } else {
layoutManager = StaggeredGridLayoutManager( layoutManager =
calculateNoOfColumns(), StaggeredGridLayoutManager(
StaggeredGridLayoutManager.VERTICAL calculateNoOfColumns(),
) StaggeredGridLayoutManager.VERTICAL,
)
layoutManager.gapStrategy = layoutManager.gapStrategy =
StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
} }
} }
@ -336,39 +359,42 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
} }
private fun handleBottomBarActions() { private fun handleBottomBarActions() {
binding.bottomBar.setTabSelectedListener(object : BottomNavigationBar.OnTabSelectedListener { binding.bottomBar.setTabSelectedListener(
override fun onTabUnselected(position: Int) = Unit object : BottomNavigationBar.OnTabSelectedListener {
override fun onTabUnselected(position: Int) = Unit
override fun onTabReselected(position: Int) { override fun onTabReselected(position: Int) {
when (val layoutManager = binding.recyclerView.adapter) {
is StaggeredGridLayoutManager ->
if (layoutManager.findFirstCompletelyVisibleItemPositions(null)[0] == 0) {
getElementsAccordingToTab()
} else {
layoutManager.scrollToPositionWithOffset(0, 0)
}
when (val layoutManager = binding.recyclerView.adapter) { is GridLayoutManager ->
is StaggeredGridLayoutManager -> if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0) {
if (layoutManager.findFirstCompletelyVisibleItemPositions(null)[0] == 0) { getElementsAccordingToTab()
getElementsAccordingToTab() } else {
} else { layoutManager.scrollToPositionWithOffset(0, 0)
layoutManager.scrollToPositionWithOffset(0, 0) }
}
is GridLayoutManager -> else -> Unit
if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0) { }
getElementsAccordingToTab()
} else {
layoutManager.scrollToPositionWithOffset(0, 0)
}
else -> Unit
} }
}
override fun onTabSelected(position: Int) { override fun onTabSelected(position: Int) {
offset = 0 offset = 0
lastFetchDone = false lastFetchDone = false
elementsShown = ItemType.fromInt(position + 1) elementsShown = ItemType.fromInt(position + 1)
getElementsAccordingToTab() getElementsAccordingToTab()
binding.recyclerView.scrollToPosition(0) binding.recyclerView.scrollToPosition(0)
fetchOnEmptyList() fetchOnEmptyList()
} }
}) },
)
} }
fun fetchOnEmptyList() { fun fetchOnEmptyList() {
@ -378,27 +404,34 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
} }
private fun handleInfiniteScroll() { private fun handleInfiniteScroll() {
recyclerViewScrollListener = object : RecyclerView.OnScrollListener() { recyclerViewScrollListener =
override fun onScrolled(localRecycler: RecyclerView, dx: Int, dy: Int) { object : RecyclerView.OnScrollListener() {
if (dy > 0) { override fun onScrolled(
val lastVisibleItem = getLastVisibleItem() localRecycler: RecyclerView,
dx: Int,
dy: Int,
) {
if (dy > 0) {
val lastVisibleItem = getLastVisibleItem()
if (lastVisibleItem == (items.size - 1) && items.size < maxItemNumber()) { if (lastVisibleItem == (items.size - 1) && items.size < maxItemNumber()) {
getElementsAccordingToTab(appendResults = true) getElementsAccordingToTab(appendResults = true)
}
} }
} }
} }
}
binding.recyclerView.clearOnScrollListeners() binding.recyclerView.clearOnScrollListeners()
binding.recyclerView.addOnScrollListener(recyclerViewScrollListener) binding.recyclerView.addOnScrollListener(recyclerViewScrollListener)
} }
private fun getLastVisibleItem() : Int { private fun getLastVisibleItem(): Int {
return when (val manager = binding.recyclerView.layoutManager) { return when (val manager = binding.recyclerView.layoutManager) {
is StaggeredGridLayoutManager -> manager.findLastCompletelyVisibleItemPositions( is StaggeredGridLayoutManager ->
null manager.findLastCompletelyVisibleItemPositions(
).last() null,
).last()
is GridLayoutManager -> manager.findLastCompletelyVisibleItemPosition() is GridLayoutManager -> manager.findLastCompletelyVisibleItemPosition()
else -> 0 else -> 0
} }
@ -411,73 +444,81 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
binding.emptyText.visibility = View.GONE binding.emptyText.visibility = View.GONE
} }
fun getElementsAccordingToTab( fun getElementsAccordingToTab(appendResults: Boolean = false) {
appendResults: Boolean = false offset =
) { if (appendResults && items.size > 0) {
offset = if (appendResults && items.size > 0) { items.size - 1
items.size - 1 } else {
} else { 0
0 }
}
firstVisible = if (appendResults) firstVisible else 0 firstVisible = if (appendResults) firstVisible else 0
getItems(appendResults, elementsShown) getItems(appendResults, elementsShown)
} }
private fun getItems(appendResults: Boolean, itemType: ItemType) { private fun getItems(
appendResults: Boolean,
itemType: ItemType,
) {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
binding.swipeRefreshLayout.isRefreshing = true binding.swipeRefreshLayout.isRefreshing = true
repository.displayedItems = itemType repository.displayedItems = itemType
items = if (appendResults) { items =
repository.getOlderItems() if (appendResults) {
} else { repository.getOlderItems()
repository.getNewerItems() } else {
} repository.getNewerItems()
}
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
handleListResult() handleListResult()
CountingIdlingResourceSingleton.decrement()
} }
} }
private fun handleListResult(appendResults: Boolean = false) { private fun handleListResult(appendResults: Boolean = false) {
if (appendResults) { if (appendResults) {
val oldManager = binding.recyclerView.layoutManager val oldManager = binding.recyclerView.layoutManager
firstVisible = when (oldManager) { firstVisible =
is StaggeredGridLayoutManager -> when (oldManager) {
oldManager.findFirstCompletelyVisibleItemPositions(null).last() is StaggeredGridLayoutManager ->
is GridLayoutManager -> oldManager.findFirstCompletelyVisibleItemPositions(null).last()
oldManager.findFirstCompletelyVisibleItemPosition()
else -> 0 is GridLayoutManager ->
} oldManager.findFirstCompletelyVisibleItemPosition()
else -> 0
}
} }
if (recyclerAdapter == null) { if (recyclerAdapter == null) {
if (appSettingsService.isCardViewEnabled()) { if (appSettingsService.isCardViewEnabled()) {
recyclerAdapter = recyclerAdapter =
ItemCardAdapter( ItemCardAdapter(
this, this,
items, items,
) { ) {
updateItems(it) updateItems(it)
} }
} else { } else {
recyclerAdapter = recyclerAdapter =
ItemListAdapter( ItemListAdapter(
this, this,
items, items,
) { ) {
updateItems(it) updateItems(it)
} }
binding.recyclerView.addItemDecoration( binding.recyclerView.addItemDecoration(
DividerItemDecoration( DividerItemDecoration(
this@HomeActivity, this@HomeActivity,
DividerItemDecoration.VERTICAL DividerItemDecoration.VERTICAL,
) ),
) )
} }
binding.recyclerView.adapter = recyclerAdapter binding.recyclerView.adapter = recyclerAdapter
} else { } else {
(recyclerAdapter as ItemsAdapter<*>).updateAllItems(items) recyclerAdapter!!.updateAllItems(items)
} }
reloadBadges() reloadBadges()
@ -486,8 +527,10 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
private fun reloadBadges() { private fun reloadBadges() {
if (appSettingsService.isDisplayUnreadCountEnabled() || appSettingsService.isDisplayAllCountEnabled()) { if (appSettingsService.isDisplayUnreadCountEnabled() || appSettingsService.isDisplayAllCountEnabled()) {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repository.reloadBadges() repository.reloadBadges()
CountingIdlingResourceSingleton.decrement()
} }
} }
} }
@ -523,13 +566,17 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
} }
val searchItem = menu.findItem(R.id.action_search) val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.getActionView() as SearchView val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(this) searchView.setOnQueryTextListener(this)
return true return true
} }
private fun needsConfirmation(titleRes: Int, messageRes: Int, doFn: () -> Unit) { private fun needsConfirmation(
titleRes: Int,
messageRes: Int,
doFn: () -> Unit,
) {
AlertDialog.Builder(this@HomeActivity) AlertDialog.Builder(this@HomeActivity)
.setMessage(messageRes) .setMessage(messageRes)
.setTitle(titleRes) .setTitle(titleRes)
@ -541,45 +588,57 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.issue_tracker -> {
val browserIntent =
Intent(Intent.ACTION_VIEW, Uri.parse(AppSettingsService.trackerUrl))
startActivity(browserIntent)
return true
}
R.id.action_filter -> { R.id.action_filter -> {
val filterSheetFragment = FilterSheetFragment() val filterSheetFragment = FilterSheetFragment()
filterSheetFragment.show(supportFragmentManager, FilterSheetFragment.TAG) filterSheetFragment.show(supportFragmentManager, FilterSheetFragment.TAG)
return true return true
} }
R.id.refresh -> { R.id.refresh -> {
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()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
val updatedRemote = repository.updateRemote() val updatedRemote = repository.updateRemote()
if (updatedRemote) { if (updatedRemote) {
Toast.makeText( Toast.makeText(
this@HomeActivity, this@HomeActivity,
R.string.refresh_success_response, Toast.LENGTH_LONG R.string.refresh_success_response,
Toast.LENGTH_LONG,
) )
.show() .show()
} else { } else {
Toast.makeText( Toast.makeText(
this@HomeActivity, this@HomeActivity,
R.string.refresh_failer_message, R.string.refresh_failer_message,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
} }
CountingIdlingResourceSingleton.decrement()
} }
} }
return true return true
} }
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 binding.swipeRefreshLayout.isRefreshing = true
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
val success = repository.markAllAsRead(items) val success = repository.markAllAsRead(items)
if (success) { if (success) {
Toast.makeText( Toast.makeText(
this@HomeActivity, this@HomeActivity,
R.string.all_posts_read, R.string.all_posts_read,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
tabNewBadge.removeBadge() tabNewBadge.removeBadge()
@ -588,18 +647,24 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
Toast.makeText( Toast.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()
} }
handleListResult() handleListResult()
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
CountingIdlingResourceSingleton.decrement()
} }
} }
} }
return true return true
} }
R.id.action_disconnect -> { R.id.action_disconnect -> {
needsConfirmation(R.string.confirm_disconnect_title, R.string.confirm_disconnect_description) { needsConfirmation(
R.string.confirm_disconnect_title,
R.string.confirm_disconnect_description
) {
runBlocking { runBlocking {
repository.logout() repository.logout()
} }
@ -609,14 +674,17 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
} }
return true return true
} }
R.id.action_settings -> { R.id.action_settings -> {
settingsLauncher.launch(Intent(this, SettingsActivity::class.java)) settingsLauncher.launch(Intent(this, SettingsActivity::class.java))
return true return true
} }
R.id.action_sources -> { R.id.action_sources -> {
startActivity(Intent(this, SourcesActivity::class.java)) startActivity(Intent(this, SourcesActivity::class.java))
return true return true
} }
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }
} }
@ -635,20 +703,29 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener, DIAwar
private fun handleRecurringTask() { private fun handleRecurringTask() {
if (appSettingsService.isPeriodicRefreshEnabled()) { if (appSettingsService.isPeriodicRefreshEnabled()) {
val myConstraints = Constraints.Builder() val myConstraints =
.setRequiresBatteryNotLow(true) Constraints.Builder()
.setRequiresCharging(appSettingsService.isRefreshWhenChargingOnlyEnabled()) .setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true) .setRequiresCharging(appSettingsService.isRefreshWhenChargingOnlyEnabled())
.build() .setRequiresStorageNotLow(true)
.build()
val backgroundWork = val backgroundWork =
PeriodicWorkRequestBuilder<LoadingWorker>(appSettingsService.getRefreshMinutes(), TimeUnit.MINUTES) PeriodicWorkRequestBuilder<LoadingWorker>(
appSettingsService.getRefreshMinutes(),
TimeUnit.MINUTES
)
.setConstraints(myConstraints) .setConstraints(myConstraints)
.addTag("selfoss-loading") .addTag("selfoss-loading")
.build() .build()
WorkManager.getInstance(baseContext).enqueueUniquePeriodicWork("selfoss-loading", ExistingPeriodicWorkPolicy.KEEP, backgroundWork) WorkManager.getInstance(
baseContext,
).enqueueUniquePeriodicWork(
"selfoss-loading",
ExistingPeriodicWorkPolicy.KEEP,
backgroundWork
)
} }
} }
} }

View File

@ -11,8 +11,8 @@ import bou.amine.apps.readerforselfossv2.android.databinding.ActivityImageBindin
import bou.amine.apps.readerforselfossv2.android.fragments.ImageFragment import bou.amine.apps.readerforselfossv2.android.fragments.ImageFragment
class ImageActivity : AppCompatActivity() { class ImageActivity : AppCompatActivity() {
private lateinit var allImages : ArrayList<String> private lateinit var allImages: ArrayList<String>
private var position : Int = 0 private var position: Int = 0
private lateinit var binding: ActivityImageBinding private lateinit var binding: ActivityImageBinding
@ -32,27 +32,44 @@ class ImageActivity : AppCompatActivity() {
binding.pager.adapter = ScreenSlidePagerAdapter(this) binding.pager.adapter = ScreenSlidePagerAdapter(this)
binding.pager.setCurrentItem(position, false) binding.pager.setCurrentItem(position, false)
val transitionListener = object : MotionLayout.TransitionListener { val transitionListener =
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) { object : MotionLayout.TransitionListener {
// Nothing override fun onTransitionStarted(
} motionLayout: MotionLayout?,
startId: Int,
endId: Int,
) {
// Nothing
}
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float override fun onTransitionChange(
) { motionLayout: MotionLayout?,
// Nothing startId: Int,
} endId: Int,
progress: Float,
) {
// Nothing
}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { override fun onTransitionCompleted(
if (motionLayout?.currentState == binding.root.endState) { motionLayout: MotionLayout?,
onBackPressedDispatcher.onBackPressed() currentId: Int,
overridePendingTransition(0, 0) ) {
if (motionLayout?.currentState == binding.root.endState) {
onBackPressedDispatcher.onBackPressed()
overridePendingTransition(0, 0)
}
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?,
triggerId: Int,
positive: Boolean,
progress: Float,
) {
// Nothing
} }
} }
override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float) {
// Nothing
}
}
binding.root.setTransitionListener(transitionListener) binding.root.setTransitionListener(transitionListener)
} }
@ -68,9 +85,8 @@ class ImageActivity : AppCompatActivity() {
} }
private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
override fun getItemCount(): Int = allImages.size override fun getItemCount(): Int = allImages.size
override fun createFragment(position: Int): Fragment = ImageFragment.newInstance(allImages[position]) override fun createFragment(position: Int): Fragment = ImageFragment.newInstance(allImages[position])
} }
} }

View File

@ -4,6 +4,7 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.view.Menu import android.view.Menu
@ -16,6 +17,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityLoginBinding import bou.amine.apps.readerforselfossv2.android.databinding.ActivityLoginBinding
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
import bou.amine.apps.readerforselfossv2.android.utils.isBaseUrlInvalid import bou.amine.apps.readerforselfossv2.android.utils.isBaseUrlInvalid
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
@ -28,9 +30,7 @@ import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.di.instance import org.kodein.di.instance
class LoginActivity : AppCompatActivity(), DIAware { class LoginActivity : AppCompatActivity(), DIAware {
private var inValidCount: Int = 0 private var inValidCount: Int = 0
private var isWithLogin = false private var isWithLogin = false
@ -40,7 +40,6 @@ class LoginActivity : AppCompatActivity(), DIAware {
private val repository: Repository by instance() private val repository: Repository by instance()
private val appSettingsService: AppSettingsService by instance() private val appSettingsService: AppSettingsService by instance()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -69,7 +68,6 @@ class LoginActivity : AppCompatActivity(), DIAware {
} }
private fun handleActions() { private fun handleActions() {
binding.passwordView.setOnEditorActionListener( binding.passwordView.setOnEditorActionListener(
TextView.OnEditorActionListener { _, id, _ -> TextView.OnEditorActionListener { _, id, _ ->
if (id == R.id.loginView || id == EditorInfo.IME_NULL) { if (id == R.id.loginView || id == EditorInfo.IME_NULL) {
@ -77,7 +75,7 @@ class LoginActivity : AppCompatActivity(), DIAware {
return@OnEditorActionListener true return@OnEditorActionListener true
} }
false false
} },
) )
binding.signInButton.setOnClickListener { attemptLogin() } binding.signInButton.setOnClickListener { attemptLogin() }
@ -98,16 +96,21 @@ class LoginActivity : AppCompatActivity(), DIAware {
alertDialog.setMessage(getString(R.string.base_url_error)) alertDialog.setMessage(getString(R.string.base_url_error))
alertDialog.setButton( alertDialog.setButton(
AlertDialog.BUTTON_NEUTRAL, AlertDialog.BUTTON_NEUTRAL,
"OK" "OK",
) { dialog, _ -> dialog.dismiss() } ) { dialog, _ -> dialog.dismiss() }
alertDialog.show() alertDialog.show()
} }
} }
private fun goToMain() { private fun goToMain() {
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
repository.updateApiInformation() repository.updateApiInformation()
ACRA.errorReporter.putCustomData("SELFOSS_API_VERSION", appSettingsService.getApiVersion().toString()) ACRA.errorReporter.putCustomData(
"SELFOSS_API_VERSION",
appSettingsService.getApiVersion().toString()
)
CountingIdlingResourceSingleton.decrement()
} }
val intent = Intent(this, HomeActivity::class.java) val intent = Intent(this, HomeActivity::class.java)
startActivity(intent) startActivity(intent)
@ -123,7 +126,6 @@ class LoginActivity : AppCompatActivity(), DIAware {
} }
private fun attemptLogin() { private fun attemptLogin() {
// Reset errors. // Reset errors.
binding.urlView.error = null binding.urlView.error = null
binding.loginView.error = null binding.loginView.error = null
@ -143,33 +145,40 @@ class LoginActivity : AppCompatActivity(), DIAware {
repository.refreshLoginInformation(url, login, password) repository.refreshLoginInformation(url, login, password)
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
repository.updateApiInformation() try {
repository.updateApiInformation()
} catch (e: Exception) {
if (e.message?.startsWith("No transformation found") == true) {
Toast.makeText(
applicationContext,
R.string.application_selfoss_only,
Toast.LENGTH_LONG,
).show()
preferenceError()
showProgress(false)
}
}
val result = repository.login() val result = repository.login()
if (result) { if (result) {
val (errorFetching, displaySelfossOnly) = repository.shouldBeSelfossInstance() val errorFetching = repository.checkIfFetchFails()
if (!errorFetching && !displaySelfossOnly) { if (!errorFetching) {
goToMain() goToMain()
} else { } else {
if (displaySelfossOnly) {
Toast.makeText(
applicationContext,
R.string.application_selfoss_only,
Toast.LENGTH_LONG
).show()
}
preferenceError() preferenceError()
} }
} else { } else {
preferenceError() preferenceError()
} }
showProgress(false) showProgress(false)
CountingIdlingResourceSingleton.decrement()
} }
} }
private fun failLoginDetails( private fun failLoginDetails(
password: String, password: String,
login: String login: String,
) { ) {
var lastFocusedView: View? = null var lastFocusedView: View? = null
var cancel = false var cancel = false
@ -202,7 +211,7 @@ class LoginActivity : AppCompatActivity(), DIAware {
alertDialog.setMessage(getString(R.string.text_wrong_url)) alertDialog.setMessage(getString(R.string.text_wrong_url))
alertDialog.setButton( alertDialog.setButton(
AlertDialog.BUTTON_NEUTRAL, AlertDialog.BUTTON_NEUTRAL,
"OK" "OK",
) { dialog, _ -> dialog.dismiss() } ) { dialog, _ -> dialog.dismiss() }
alertDialog.show() alertDialog.show()
inValidCount = 0 inValidCount = 0
@ -211,7 +220,10 @@ class LoginActivity : AppCompatActivity(), DIAware {
maybeCancelAndFocusView(cancel, focusView) maybeCancelAndFocusView(cancel, focusView)
} }
private fun maybeCancelAndFocusView(cancel: Boolean, focusView: View?) { private fun maybeCancelAndFocusView(
cancel: Boolean,
focusView: View?,
) {
if (cancel) { if (cancel) {
focusView?.requestFocus() focusView?.requestFocus()
} }
@ -225,12 +237,13 @@ class LoginActivity : AppCompatActivity(), DIAware {
.animate() .animate()
.setDuration(shortAnimTime.toLong()) .setDuration(shortAnimTime.toLong())
.alpha( .alpha(
if (show) 0F else 1F if (show) 0F else 1F,
).setListener(object : AnimatorListenerAdapter() { ).setListener(
override fun onAnimationEnd(animation: Animator) { object : AnimatorListenerAdapter() {
binding.loginForm.visibility = if (show) View.GONE else View.VISIBLE override fun onAnimationEnd(animation: Animator) {
} binding.loginForm.visibility = if (show) View.GONE else View.VISIBLE
} }
},
) )
binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE
@ -238,12 +251,13 @@ class LoginActivity : AppCompatActivity(), DIAware {
.animate() .animate()
.setDuration(shortAnimTime.toLong()) .setDuration(shortAnimTime.toLong())
.alpha( .alpha(
if (show) 1F else 0F if (show) 1F else 0F,
).setListener(object : AnimatorListenerAdapter() { ).setListener(
override fun onAnimationEnd(animation: Animator) { object : AnimatorListenerAdapter() {
binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE override fun onAnimationEnd(animation: Animator) {
} binding.loginProgress.visibility = if (show) View.VISIBLE else View.GONE
} }
},
) )
} }
@ -254,14 +268,26 @@ class LoginActivity : AppCompatActivity(), DIAware {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.issue_tracker -> {
val browserIntent =
Intent(Intent.ACTION_VIEW, Uri.parse(AppSettingsService.trackerUrl))
startActivity(browserIntent)
return true
}
R.id.about -> { R.id.about -> {
LibsBuilder() LibsBuilder()
.withAboutIconShown(true) .withAboutIconShown(true)
.withAboutVersionShown(true) .withAboutVersionShown(true)
.withAboutSpecial2("Bug reports")
.withAboutSpecial2Description(AppSettingsService.trackerUrl)
.withAboutSpecial1("Project Page")
.withAboutSpecial1Description(AppSettingsService.sourceUrl)
.start(this) .start(this)
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
} }

View File

@ -8,7 +8,6 @@ import bou.amine.apps.readerforselfossv2.android.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)

View File

@ -10,6 +10,7 @@ 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.DI.networkModule import bou.amine.apps.readerforselfossv2.DI.networkModule
import bou.amine.apps.readerforselfossv2.android.testing.TestingHelper
import bou.amine.apps.readerforselfossv2.android.viewmodel.AppViewModel 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
@ -29,23 +30,27 @@ import org.acra.config.toast
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender import org.acra.sender.HttpSender
import org.kodein.di.* import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.bind
import org.kodein.di.instance
import org.kodein.di.singleton
class MyApp : MultiDexApplication(), DIAware { class MyApp : MultiDexApplication(), DIAware {
override val di by DI.lazy { override val di by DI.lazy {
bind<AppSettingsService>() with singleton { AppSettingsService(ACRA.isACRASenderServiceProcess()) } bind<AppSettingsService>() with singleton { AppSettingsService(ACRA.isACRASenderServiceProcess() || TestingHelper().isUnitTest()) }
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<Repository>() with singleton { bind<Repository>() with
Repository( singleton {
instance(), Repository(
instance(), instance(),
isConnectionAvailable, instance(),
instance() isConnectionAvailable,
) instance(),
} )
}
bind<ConnectivityStatus>() with singleton { ConnectivityStatus(applicationContext) } bind<ConnectivityStatus>() with singleton { ConnectivityStatus(applicationContext) }
bind<AppViewModel>() with singleton { AppViewModel(repository = instance()) } bind<AppViewModel>() with singleton { AppViewModel(repository = instance()) }
} }
@ -70,23 +75,24 @@ class MyApp : MultiDexApplication(), DIAware {
ProcessLifecycleOwner.get().lifecycle.addObserver( ProcessLifecycleOwner.get().lifecycle.addObserver(
AppLifeCycleObserver( AppLifeCycleObserver(
connectivityStatus, connectivityStatus,
repository repository,
) ),
) )
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
viewModel.networkAvailableProvider.collect { networkAvailable -> viewModel.networkAvailableProvider.collect { networkAvailable ->
val toastMessage = if (networkAvailable) { val toastMessage =
repository.handleDBActions() if (networkAvailable) {
R.string.network_connectivity_retrieved repository.handleDBActions()
} else { R.string.network_connectivity_retrieved
R.string.network_connectivity_lost } else {
} R.string.network_connectivity_lost
}
Toast.makeText( Toast.makeText(
applicationContext, applicationContext,
toastMessage, toastMessage,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
} }
} }
@ -100,37 +106,38 @@ class MyApp : MultiDexApplication(), DIAware {
initAcra { initAcra {
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
reportContent = listOf( reportContent =
ReportField.REPORT_ID, listOf(
ReportField.INSTALLATION_ID, ReportField.REPORT_ID,
ReportField.APP_VERSION_CODE, ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_CODE,
ReportField.BUILD, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.BUILD,
ReportField.BRAND, ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL, ReportField.BRAND,
ReportField.AVAILABLE_MEM_SIZE, ReportField.PHONE_MODEL,
ReportField.TOTAL_MEM_SIZE, ReportField.AVAILABLE_MEM_SIZE,
ReportField.STACK_TRACE, ReportField.TOTAL_MEM_SIZE,
ReportField.APPLICATION_LOG, ReportField.STACK_TRACE,
ReportField.LOGCAT, ReportField.APPLICATION_LOG,
ReportField.INITIAL_CONFIGURATION, ReportField.LOGCAT,
ReportField.CRASH_CONFIGURATION, ReportField.INITIAL_CONFIGURATION,
ReportField.IS_SILENT, ReportField.CRASH_CONFIGURATION,
ReportField.USER_APP_START_DATE, ReportField.IS_SILENT,
ReportField.USER_COMMENT, ReportField.USER_APP_START_DATE,
ReportField.USER_CRASH_DATE, ReportField.USER_COMMENT,
ReportField.USER_EMAIL, ReportField.USER_CRASH_DATE,
ReportField.CUSTOM_DATA ReportField.USER_EMAIL,
) ReportField.CUSTOM_DATA,
)
toast { toast {
//required // required
text = getString(R.string.crash_toast_text) text = getString(R.string.crash_toast_text)
length = Toast.LENGTH_SHORT length = Toast.LENGTH_SHORT
} }
httpSender { httpSender {
uri = uri =
"https://bugs.amine-louveau.fr/report" /*best guess, you may need to adjust this*/ "https://bugs.amine-bouabdallaoui.fr/report" // best guess, you may need to adjust this
basicAuthLogin = "qMEscjj89Gwt6cPR" basicAuthLogin = "qMEscjj89Gwt6cPR"
basicAuthPassword = "Yo58QFlGzFaWlBzP" basicAuthPassword = "Yo58QFlGzFaWlBzP"
httpMethod = HttpSender.Method.POST httpMethod = HttpSender.Method.POST
@ -148,11 +155,12 @@ class MyApp : MultiDexApplication(), DIAware {
val newItemsChannelname = getString(R.string.new_items_channel_sync) val newItemsChannelname = getString(R.string.new_items_channel_sync)
val newItemsChannelimportance = NotificationManager.IMPORTANCE_DEFAULT val newItemsChannelimportance = NotificationManager.IMPORTANCE_DEFAULT
val newItemsChannelmChannel = NotificationChannel( val newItemsChannelmChannel =
AppSettingsService.newItemsChannelId, NotificationChannel(
newItemsChannelname, AppSettingsService.newItemsChannelId,
newItemsChannelimportance newItemsChannelname,
) newItemsChannelimportance,
)
notificationManager.createNotificationChannel(mChannel) notificationManager.createNotificationChannel(mChannel)
notificationManager.createNotificationChannel(newItemsChannelmChannel) notificationManager.createNotificationChannel(newItemsChannelmChannel)
@ -163,9 +171,11 @@ class MyApp : MultiDexApplication(), DIAware {
val oldHandler = Thread.getDefaultUncaughtExceptionHandler() val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, e -> Thread.setDefaultUncaughtExceptionHandler { thread, e ->
if (e is NoClassDefFoundError && e.stackTrace.asList().any { if (e is NoClassDefFoundError &&
e.stackTrace.asList().any {
it.toString().contains("android.view.ViewDebug") it.toString().contains("android.view.ViewDebug")
}) { }
) {
// Nothing // Nothing
} else { } else {
oldHandler.uncaughtException(thread, e) oldHandler.uncaughtException(thread, e)
@ -175,9 +185,8 @@ class MyApp : MultiDexApplication(), DIAware {
class AppLifeCycleObserver( class AppLifeCycleObserver(
val connectivityStatus: ConnectivityStatus, val connectivityStatus: ConnectivityStatus,
val repository: Repository val repository: Repository,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
super.onResume(owner) super.onResume(owner)
repository.connectionMonitored = true repository.connectionMonitored = true

View File

@ -23,7 +23,6 @@ import org.kodein.di.android.closestDI
import org.kodein.di.instance import org.kodein.di.instance
class ReaderActivity : AppCompatActivity(), DIAware { class ReaderActivity : AppCompatActivity(), DIAware {
private var currentItem: Int = 0 private var currentItem: Int = 0
private lateinit var toolbarMenu: Menu private lateinit var toolbarMenu: Menu
@ -102,15 +101,15 @@ class ReaderActivity : AppCompatActivity(), DIAware {
private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) :
FragmentStateAdapter(fa) { FragmentStateAdapter(fa) {
override fun getItemCount(): Int = allItems.size override fun getItemCount(): Int = allItems.size
override fun createFragment(position: Int): Fragment = override fun createFragment(position: Int): Fragment = ArticleFragment.newInstance(allItems[position])
ArticleFragment.newInstance(allItems[position])
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(
keyCode: Int,
event: KeyEvent?,
): Boolean {
return when (keyCode) { return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
val currentFragment = val currentFragment =
@ -152,10 +151,8 @@ class ReaderActivity : AppCompatActivity(), DIAware {
canFavorite() canFavorite()
} }
binding.pager.registerOnPageChangeCallback( binding.pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() { object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
super.onPageSelected(position) super.onPageSelected(position)
@ -166,7 +163,7 @@ class ReaderActivity : AppCompatActivity(), DIAware {
} }
readItem(allItems[position]) readItem(allItems[position])
} }
} },
) )
} }

View File

@ -8,6 +8,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import bou.amine.apps.readerforselfossv2.android.adapters.SourcesListAdapter import bou.amine.apps.readerforselfossv2.android.adapters.SourcesListAdapter
import bou.amine.apps.readerforselfossv2.android.databinding.ActivitySourcesBinding import bou.amine.apps.readerforselfossv2.android.databinding.ActivitySourcesBinding
import bou.amine.apps.readerforselfossv2.android.testing.CountingIdlingResourceSingleton
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
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -18,11 +19,10 @@ import org.kodein.di.android.closestDI
import org.kodein.di.instance import org.kodein.di.instance
class SourcesActivity : AppCompatActivity(), DIAware { class SourcesActivity : AppCompatActivity(), DIAware {
private lateinit var binding: ActivitySourcesBinding private lateinit var binding: ActivitySourcesBinding
override val di by closestDI() override val di by closestDI()
private val repository : Repository by instance() private val repository: Repository by instance()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
binding = ActivitySourcesBinding.inflate(layoutInflater) binding = ActivitySourcesBinding.inflate(layoutInflater)
@ -37,7 +37,8 @@ class SourcesActivity : AppCompatActivity(), DIAware {
supportActionBar?.setDisplayShowHomeEnabled(true) supportActionBar?.setDisplayShowHomeEnabled(true)
binding.fab.rippleColor = resources.getColor(R.color.colorAccentDark) binding.fab.rippleColor = resources.getColor(R.color.colorAccentDark)
binding.fab.backgroundTintList = ColorStateList.valueOf(resources.getColor(R.color.colorAccent)) binding.fab.backgroundTintList =
ColorStateList.valueOf(resources.getColor(R.color.colorAccent))
} }
override fun onStop() { override fun onStop() {
@ -54,26 +55,30 @@ class SourcesActivity : AppCompatActivity(), DIAware {
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = mLayoutManager binding.recyclerView.layoutManager = mLayoutManager
CountingIdlingResourceSingleton.increment()
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
val response = repository.getSourcesDetails() val response = repository.getSourcesDetails()
if (response.isNotEmpty()) { if (response.isNotEmpty()) {
items = response items = response
val mAdapter = SourcesListAdapter( val mAdapter =
this@SourcesActivity, items SourcesListAdapter(
) this@SourcesActivity,
items,
)
binding.recyclerView.adapter = mAdapter binding.recyclerView.adapter = mAdapter
mAdapter.notifyDataSetChanged() mAdapter.notifyDataSetChanged()
} else { } else {
Toast.makeText( Toast.makeText(
this@SourcesActivity, this@SourcesActivity,
R.string.cant_get_sources, R.string.cant_get_sources,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
} }
CountingIdlingResourceSingleton.decrement()
} }
binding.fab.setOnClickListener { binding.fab.setOnClickListener {
startActivity(Intent(this@SourcesActivity, UpsertSourceActivity::class.java)) startActivity(Intent(this@SourcesActivity, UpsertSourceActivity::class.java))
} }
} }
} }

View File

@ -21,9 +21,7 @@ import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.di.instance import org.kodein.di.instance
class UpsertSourceActivity : AppCompatActivity(), DIAware { class UpsertSourceActivity : AppCompatActivity(), DIAware {
private var existingSource: SelfossModel.SourceDetail? = null private var existingSource: SelfossModel.SourceDetail? = null
private var mSpoutsValue: String? = null private var mSpoutsValue: String? = null
@ -58,7 +56,6 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
supportActionBar?.setDisplayShowHomeEnabled(true) supportActionBar?.setDisplayShowHomeEnabled(true)
supportActionBar?.title = resources.getString(title) supportActionBar?.title = resources.getString(title)
maybeGetDetailsFromIntentSharing(intent) maybeGetDetailsFromIntentSharing(intent)
binding.saveBtn.setOnClickListener { binding.saveBtn.setOnClickListener {
@ -88,25 +85,30 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
private fun handleSpoutsSpinner() { private fun handleSpoutsSpinner() {
val spoutsKV = HashMap<String, String>() val spoutsKV = HashMap<String, String>()
binding.spoutsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { binding.spoutsSpinner.onItemSelectedListener =
override fun onItemSelected(adapterView: AdapterView<*>, view: View?, i: Int, l: Long) { object : AdapterView.OnItemSelectedListener {
if (view != null) { override fun onItemSelected(
val spoutName = (view as TextView).text.toString() adapterView: AdapterView<*>,
mSpoutsValue = spoutsKV[spoutName] view: View?,
i: Int,
l: Long,
) {
if (view != null) {
val spoutName = (view as TextView).text.toString()
mSpoutsValue = spoutsKV[spoutName]
}
}
override fun onNothingSelected(adapterView: AdapterView<*>) {
mSpoutsValue = null
} }
} }
override fun onNothingSelected(adapterView: AdapterView<*>) {
mSpoutsValue = null
}
}
fun handleSpoutFailure(networkIssue: Boolean = false) { fun handleSpoutFailure(networkIssue: Boolean = false) {
Toast.makeText( Toast.makeText(
this@UpsertSourceActivity, this@UpsertSourceActivity,
if (networkIssue) R.string.cant_get_spouts_no_network else R.string.cant_get_spouts, if (networkIssue) R.string.cant_get_spouts_no_network else R.string.cant_get_spouts,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
binding.progress.visibility = View.GONE binding.progress.visibility = View.GONE
} }
@ -127,7 +129,7 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
ArrayAdapter( ArrayAdapter(
this@UpsertSourceActivity, this@UpsertSourceActivity,
android.R.layout.simple_spinner_item, android.R.layout.simple_spinner_item,
itemsStrings itemsStrings,
) )
spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spoutsSpinner.adapter = spinnerArrayAdapter binding.spoutsSpinner.adapter = spinnerArrayAdapter
@ -144,9 +146,7 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
} }
} }
private fun maybeGetDetailsFromIntentSharing( private fun maybeGetDetailsFromIntentSharing(intent: Intent) {
intent: Intent
) {
if (Intent.ACTION_SEND == intent.action && "text/plain" == intent.type) { if (Intent.ACTION_SEND == intent.action && "text/plain" == intent.type) {
binding.sourceUri.setText(intent.getStringExtra(Intent.EXTRA_TEXT)) binding.sourceUri.setText(intent.getStringExtra(Intent.EXTRA_TEXT))
binding.nameInput.setText(intent.getStringExtra(Intent.EXTRA_TITLE)) binding.nameInput.setText(intent.getStringExtra(Intent.EXTRA_TITLE))
@ -172,29 +172,30 @@ class UpsertSourceActivity : AppCompatActivity(), DIAware {
} }
else -> { else -> {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
val successfullyAddedSource = if (existingSource != null) { val successfullyAddedSource =
repository.updateSource( if (existingSource != null) {
existingSource!!.id, repository.updateSource(
binding.nameInput.text.toString(), existingSource!!.id,
url, binding.nameInput.text.toString(),
mSpoutsValue!!, url,
binding.tags.text.toString() mSpoutsValue!!,
) binding.tags.text.toString(),
} else { )
repository.createSource( } else {
binding.nameInput.text.toString(), repository.createSource(
url, binding.nameInput.text.toString(),
mSpoutsValue!!, url,
binding.tags.text.toString(), mSpoutsValue!!,
) binding.tags.text.toString(),
} )
}
if (successfullyAddedSource) { if (successfullyAddedSource) {
finish() finish()
} else { } else {
Toast.makeText( Toast.makeText(
this@UpsertSourceActivity, this@UpsertSourceActivity,
R.string.cant_create_source, R.string.cant_create_source,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
} }
} }

View File

@ -1,7 +1,6 @@
package bou.amine.apps.readerforselfossv2.android.adapters package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity import android.app.Activity
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -9,11 +8,11 @@ import android.widget.ImageView.ScaleType
import androidx.recyclerview.widget.RecyclerView 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.databinding.CardItemBinding import bou.amine.apps.readerforselfossv2.android.databinding.CardItemBinding
import bou.amine.apps.readerforselfossv2.android.sendSilentlyWithAcraWithName
import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener
import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable
import bou.amine.apps.readerforselfossv2.android.utils.openInBrowserAsNewTask import bou.amine.apps.readerforselfossv2.android.utils.openInBrowserAsNewTask
import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl
import bou.amine.apps.readerforselfossv2.android.utils.shareLink import bou.amine.apps.readerforselfossv2.android.utils.shareLink
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
@ -31,10 +30,10 @@ import org.kodein.di.instance
class ItemCardAdapter( class ItemCardAdapter(
override val app: Activity, override val app: Activity,
override var items: ArrayList<SelfossModel.Item>, override val items: ArrayList<SelfossModel.Item>,
override val updateItems: (ArrayList<SelfossModel.Item>) -> Unit override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit,
) : ItemsAdapter<ItemCardAdapter.ViewHolder>() { ) : ItemsAdapter<ItemCardAdapter.ViewHolder>() {
private val c: Context = app.baseContext override lateinit var binding: CardItemBinding
private val imageMaxHeight: Int = private val imageMaxHeight: Int =
c.resources.getDimension(R.dimen.card_image_max_height).toInt() c.resources.getDimension(R.dimen.card_image_max_height).toInt()
@ -42,15 +41,50 @@ class ItemCardAdapter(
override val repository: Repository by instance() override val repository: Repository by instance()
override val appSettingsService: AppSettingsService by instance() override val appSettingsService: AppSettingsService by instance()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(
val binding = CardItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) parent: ViewGroup,
viewType: Int,
): ViewHolder {
binding = CardItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding) return ViewHolder(binding)
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { private fun handleClickListeners(holderBinding: CardItemBinding, position: Int) {
holderBinding.favButton.setOnClickListener {
val item = items[position]
if (item.starred) {
CoroutineScope(Dispatchers.IO).launch {
repository.unstarr(item)
}
binding.favButton.isSelected = false
} else {
CoroutineScope(Dispatchers.IO).launch {
repository.starr(item)
}
binding.favButton.isSelected = true
}
}
binding.shareBtn.setOnClickListener {
val item = items[position]
c.shareLink(item.getLinkDecoded(), item.title.getHtmlDecoded())
}
binding.browserBtn.setOnClickListener {
c.openInBrowserAsNewTask(items[position])
}
}
override fun onBindViewHolder(
holder: ViewHolder,
position: Int,
) {
with(holder) { with(holder) {
val itm = items[position] val itm = items[position]
handleClickListeners(binding, position)
handleLinkOpening(binding, position)
binding.favButton.isSelected = itm.starred binding.favButton.isSelected = itm.starred
if (appSettingsService.getPublicAccess()) { if (appSettingsService.getPublicAccess()) {
binding.favButton.visibility = View.GONE binding.favButton.visibility = View.GONE
@ -62,7 +96,12 @@ class ItemCardAdapter(
binding.title.setLinkTextColor(c.resources.getColor(R.color.colorAccent)) binding.title.setLinkTextColor(c.resources.getColor(R.color.colorAccent))
binding.sourceTitleAndDate.text = itm.sourceAuthorAndDate() binding.sourceTitleAndDate.text = try {
itm.sourceAuthorAndDate()
} catch (e: Exception) {
e.sendSilentlyWithAcraWithName("ItemCardAdapter parse date")
itm.sourceAuthorOnly()
}
if (!appSettingsService.isFullHeightCardsEnabled()) { if (!appSettingsService.isFullHeightCardsEnabled()) {
binding.itemImage.maxHeight = imageMaxHeight binding.itemImage.maxHeight = imageMaxHeight
@ -86,53 +125,5 @@ class ItemCardAdapter(
} }
} }
override fun getItemCount(): Int { inner class ViewHolder(val binding: CardItemBinding) : RecyclerView.ViewHolder(binding.root)
return items.size
}
inner class ViewHolder(val binding: CardItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
handleClickListeners()
handleLinkOpening()
}
private fun handleClickListeners() {
binding.favButton.setOnClickListener {
val item = items[bindingAdapterPosition]
if (item.starred) {
CoroutineScope(Dispatchers.IO).launch {
repository.unstarr(item)
}
binding.favButton.isSelected = false
} else {
CoroutineScope(Dispatchers.IO).launch {
repository.starr(item)
}
binding.favButton.isSelected = true
}
}
binding.shareBtn.setOnClickListener {
val item = items[bindingAdapterPosition]
c.shareLink(item.getLinkDecoded(), item.title.getHtmlDecoded())
}
binding.browserBtn.setOnClickListener {
c.openInBrowserAsNewTask(items[bindingAdapterPosition])
}
}
private fun handleLinkOpening() {
binding.root.setOnClickListener {
repository.setReaderItems(items)
c.openItemUrl(
bindingAdapterPosition,
items[bindingAdapterPosition].getLinkDecoded(),
appSettingsService.isArticleViewerEnabled(),
app
)
}
}
}
} }

View File

@ -1,15 +1,14 @@
package bou.amine.apps.readerforselfossv2.android.adapters package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity import android.app.Activity
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView 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.databinding.ListItemBinding import bou.amine.apps.readerforselfossv2.android.databinding.ListItemBinding
import bou.amine.apps.readerforselfossv2.android.sendSilentlyWithAcraWithName
import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable import bou.amine.apps.readerforselfossv2.android.utils.glide.circularDrawable
import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl
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
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
@ -22,34 +21,46 @@ import org.kodein.di.instance
class ItemListAdapter( class ItemListAdapter(
override val app: Activity, override val app: Activity,
override var items: ArrayList<SelfossModel.Item>, override val items: ArrayList<SelfossModel.Item>,
override val updateItems: (ArrayList<SelfossModel.Item>) -> Unit override val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit,
) : ItemsAdapter<ItemListAdapter.ViewHolder>() { ) : ItemsAdapter<ItemListAdapter.ViewHolder>() {
private val c: Context = app.baseContext override lateinit var binding: ListItemBinding
override val di: DI by closestDI(app) override val di: DI by closestDI(app)
override val repository : Repository by instance() override val repository: Repository by instance()
override val appSettingsService : AppSettingsService by instance() override val appSettingsService: AppSettingsService by instance()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(
val binding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) parent: ViewGroup,
viewType: Int,
): ViewHolder {
binding = ListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding) return ViewHolder(binding)
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(
holder: ViewHolder,
position: Int,
) {
with(holder) { with(holder) {
val itm = items[position] val itm = items[position]
handleLinkOpening(binding, position)
binding.title.text = itm.title.getHtmlDecoded() binding.title.text = itm.title.getHtmlDecoded()
binding.title.setOnTouchListener(LinkOnTouchListener()) binding.title.setOnTouchListener(LinkOnTouchListener())
binding.title.setLinkTextColor(c.resources.getColor(R.color.colorAccent)) binding.title.setLinkTextColor(c.resources.getColor(R.color.colorAccent))
binding.sourceTitleAndDate.text = itm.sourceAuthorAndDate() binding.sourceTitleAndDate.text = try {
itm.sourceAuthorAndDate()
} catch (e: Exception) {
e.sendSilentlyWithAcraWithName("ItemListAdapter parse date")
itm.sourceAuthorOnly()
}
if (itm.getThumbnail(repository.baseUrl).isEmpty()) { if (itm.getThumbnail(repository.baseUrl).isEmpty()) {
if (itm.getIcon(repository.baseUrl).isEmpty()) { if (itm.getIcon(repository.baseUrl).isEmpty()) {
binding.itemImage.setBackgroundAndText(itm.sourcetitle.getHtmlDecoded()) binding.itemImage.setBackgroundAndText(itm.sourcetitle.getHtmlDecoded())
} else { } else {
@ -61,24 +72,5 @@ class ItemListAdapter(
} }
} }
override fun getItemCount(): Int = items.size inner class ViewHolder(val binding: ListItemBinding) : RecyclerView.ViewHolder(binding.root)
inner class ViewHolder(val binding: ListItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
handleLinkOpening()
}
private fun handleLinkOpening() {
binding.root.setOnClickListener {
repository.setReaderItems(items)
c.openItemUrl(
bindingAdapterPosition,
items[bindingAdapterPosition].getLinkDecoded(),
appSettingsService.isArticleViewerEnabled(),
app
)
}
}
}
} }

View File

@ -1,10 +1,13 @@
package bou.amine.apps.readerforselfossv2.android.adapters package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity import android.app.Activity
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import bou.amine.apps.readerforselfossv2.android.R import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl
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
import bou.amine.apps.readerforselfossv2.service.AppSettingsService import bou.amine.apps.readerforselfossv2.service.AppSettingsService
@ -16,28 +19,36 @@ import kotlinx.coroutines.launch
import org.kodein.di.DIAware import org.kodein.di.DIAware
abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapter<VH>(), DIAware { abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapter<VH>(), DIAware {
abstract var items: ArrayList<SelfossModel.Item> abstract val items: ArrayList<SelfossModel.Item>
abstract val repository: Repository abstract val repository: Repository
abstract val binding: ViewBinding
abstract val appSettingsService: AppSettingsService abstract val appSettingsService: AppSettingsService
abstract val app: Activity abstract val app: Activity
abstract val updateItems: (ArrayList<SelfossModel.Item>) -> Unit abstract val updateHomeItems: (ArrayList<SelfossModel.Item>) -> Unit
protected val c: Context get() = app.baseContext
fun updateAllItems(items: ArrayList<SelfossModel.Item>) { fun updateAllItems(items: ArrayList<SelfossModel.Item>) {
this.items = items this.items.clear()
this.items.addAll(items)
updateHomeItems(items)
notifyDataSetChanged() notifyDataSetChanged()
updateItems(this.items)
} }
private fun unmarkSnackbar(item: SelfossModel.Item, position: Int) { private fun unmarkSnackbar(
val s = Snackbar item: SelfossModel.Item,
.make( position: Int,
app.findViewById(R.id.coordLayout), ) {
R.string.marked_as_read, val s =
Snackbar.LENGTH_LONG Snackbar
) .make(
.setAction(R.string.undo_string) { app.findViewById(R.id.coordLayout),
unreadItemAtIndex(item, position, false) R.string.marked_as_read,
} Snackbar.LENGTH_LONG,
)
.setAction(R.string.undo_string) {
unreadItemAtIndex(item, position, false)
}
val view = s.view val view = s.view
val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text) val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text)
@ -45,16 +56,20 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
s.show() s.show()
} }
private fun markSnackbar(item: SelfossModel.Item, position: Int) { private fun markSnackbar(
val s = Snackbar item: SelfossModel.Item,
.make( position: Int,
app.findViewById(R.id.coordLayout), ) {
R.string.marked_as_unread, val s =
Snackbar.LENGTH_LONG Snackbar
) .make(
.setAction(R.string.undo_string) { app.findViewById(R.id.coordLayout),
readItemAtIndex(item, position, false) R.string.marked_as_unread,
} Snackbar.LENGTH_LONG,
)
.setAction(R.string.undo_string) {
readItemAtIndex(item, position, false)
}
val view = s.view val view = s.view
val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text) val tv: TextView = view.findViewById(com.google.android.material.R.id.snackbar_text)
@ -62,6 +77,18 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
s.show() s.show()
} }
protected fun handleLinkOpening(holderBinding: ViewBinding, position: Int) {
holderBinding.root.setOnClickListener {
repository.setReaderItems(items)
c.openItemUrl(
position,
items[position].getLinkDecoded(),
appSettingsService.isArticleViewerEnabled(),
app,
)
}
}
fun handleItemAtIndex(position: Int) { fun handleItemAtIndex(position: Int) {
if (items[position].unread) { if (items[position].unread) {
readItemAtIndex(items[position], position) readItemAtIndex(items[position], position)
@ -70,14 +97,19 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
} }
} }
private fun readItemAtIndex(item: SelfossModel.Item, position: Int, showSnackbar: Boolean = true) { private fun readItemAtIndex(
item: SelfossModel.Item,
position: Int,
showSnackbar: Boolean = true,
) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repository.markAsRead(item) repository.markAsRead(item)
} }
if (repository.displayedItems == ItemType.UNREAD) { if (repository.displayedItems == ItemType.UNREAD) {
items.remove(item) items.remove(item)
notifyItemRemoved(position) notifyItemRemoved(position)
updateItems(items) notifyItemRangeChanged(position, itemCount)
updateHomeItems(items)
} else { } else {
notifyItemChanged(position) notifyItemChanged(position)
} }
@ -86,10 +118,13 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
} }
} }
private fun unreadItemAtIndex(item: SelfossModel.Item, position: Int, showSnackbar: Boolean = true) { private fun unreadItemAtIndex(
item: SelfossModel.Item,
position: Int,
showSnackbar: Boolean = true,
) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
repository.unmarkAsRead(item) repository.unmarkAsRead(item)
} }
notifyItemChanged(position) notifyItemChanged(position)
if (showSnackbar) { if (showSnackbar) {
@ -97,18 +132,21 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
} }
} }
fun addItemAtIndex(item: SelfossModel.Item, position: Int) { fun addItemAtIndex(
item: SelfossModel.Item,
position: Int,
) {
items.add(position, item) items.add(position, item)
notifyItemInserted(position) notifyItemInserted(position)
updateItems(items) updateHomeItems(items)
} }
fun addItemsAtEnd(newItems: List<SelfossModel.Item>) { fun addItemsAtEnd(newItems: List<SelfossModel.Item>) {
val oldSize = items.size val oldSize = items.size
items.addAll(newItems) items.addAll(newItems)
notifyItemRangeInserted(oldSize, newItems.size) notifyItemRangeInserted(oldSize, newItems.size)
updateItems(items) updateHomeItems(items)
} }
}
override fun getItemCount(): Int = items.size
}

View File

@ -28,22 +28,55 @@ import org.kodein.di.instance
class SourcesListAdapter( class SourcesListAdapter(
private val app: Activity, private val app: Activity,
private val items: ArrayList<SelfossModel.SourceDetail> private val items: ArrayList<SelfossModel.SourceDetail>,
) : RecyclerView.Adapter<SourcesListAdapter.ViewHolder>(), DIAware { ) : RecyclerView.Adapter<SourcesListAdapter.ViewHolder>(), DIAware {
private val c: Context = app.baseContext private val c: Context = app.baseContext
private lateinit var binding: SourceListItemBinding private lateinit var binding: SourceListItemBinding
override val di: DI by closestDI(app) override val di: DI by closestDI(app)
private val repository : Repository by instance() private val repository: Repository by instance()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): ViewHolder {
binding = SourceListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding = SourceListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding.root) return ViewHolder(binding.root)
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(
holder: ViewHolder,
position: Int,
) {
val itm = items[position] val itm = items[position]
val deleteBtn: Button = holder.mView.findViewById(R.id.deleteBtn)
deleteBtn.setOnClickListener {
val (id, title) = items[position]
CoroutineScope(Dispatchers.IO).launch {
val successfullyDeletedSource = repository.deleteSource(id, title)
if (successfullyDeletedSource) {
items.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount)
} else {
Toast.makeText(
app,
R.string.can_delete_source,
Toast.LENGTH_SHORT,
).show()
}
}
}
holder.mView.setOnClickListener {
val source = items[position]
repository.setSelectedSource(source)
app.startActivity(Intent(app, UpsertSourceActivity::class.java))
}
if (itm.getIcon(repository.baseUrl).isEmpty()) { if (itm.getIcon(repository.baseUrl).isEmpty()) {
binding.itemImage.setBackgroundAndText(itm.title.getHtmlDecoded()) binding.itemImage.setBackgroundAndText(itm.title.getHtmlDecoded())
} else { } else {
@ -66,41 +99,5 @@ class SourcesListAdapter(
override fun getItemCount(): Int = items.size override fun getItemCount(): Int = items.size
inner class ViewHolder(private val mView: ConstraintLayout) : RecyclerView.ViewHolder(mView) { inner class ViewHolder(val mView: ConstraintLayout) : RecyclerView.ViewHolder(mView)
init {
handleClickListeners()
}
private fun handleClickListeners() {
val deleteBtn: Button = mView.findViewById(R.id.deleteBtn)
deleteBtn.setOnClickListener {
val (id, title) = items[bindingAdapterPosition]
CoroutineScope(Dispatchers.IO).launch {
val successfullyDeletedSource = repository.deleteSource(id, title)
if (successfullyDeletedSource) {
items.removeAt(bindingAdapterPosition)
notifyItemRemoved(bindingAdapterPosition)
notifyItemRangeChanged(bindingAdapterPosition, itemCount)
} else {
Toast.makeText(
app,
R.string.can_delete_source,
Toast.LENGTH_SHORT
).show()
}
}
}
mView.setOnClickListener {
val source = items[bindingAdapterPosition]
repository.setSelectedSource(source)
app.startActivity(Intent(app, UpsertSourceActivity::class.java))
}
}
}
} }

View File

@ -26,16 +26,15 @@ import org.kodein.di.instance
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
class LoadingWorker(val context: Context, params: WorkerParameters) : Worker(context, params), class LoadingWorker(val context: Context, params: WorkerParameters) :
Worker(context, params),
DIAware { DIAware {
override val di by lazy { (applicationContext as MyApp).di } override val di by lazy { (applicationContext as MyApp).di }
private val repository: Repository by instance() private val repository: Repository by instance()
private val appSettingsService: AppSettingsService by instance() private val appSettingsService: AppSettingsService by instance()
override fun doWork(): Result { override fun doWork(): Result {
if (appSettingsService.isPeriodicRefreshEnabled() && isNetworkAccessible(context)) { if (appSettingsService.isPeriodicRefreshEnabled() && isNetworkAccessible(context)) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val notificationManager = val notificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@ -67,37 +66,37 @@ class LoadingWorker(val context: Context, params: WorkerParameters) : Worker(con
private fun handleNewItemsNotification( private fun handleNewItemsNotification(
newItems: List<SelfossModel.Item>?, newItems: List<SelfossModel.Item>?,
notificationManager: NotificationManager notificationManager: NotificationManager,
) { ) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val apiItems = newItems.orEmpty() val apiItems = newItems.orEmpty()
val newSize = apiItems.filter { it.unread }.size val newSize = apiItems.filter { it.unread }.size
if (newSize > 0) { if (newSize > 0) {
val intent =
val intent = Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
} }
val pflags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pflags =
PendingIntent.FLAG_IMMUTABLE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
} else { PendingIntent.FLAG_IMMUTABLE
0 } else {
} 0
}
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
PendingIntent.getActivity(context, 0, intent, pflags) PendingIntent.getActivity(context, 0, intent, pflags)
val newItemsNotification = val newItemsNotification =
NotificationCompat.Builder( NotificationCompat.Builder(
applicationContext, applicationContext,
AppSettingsService.newItemsChannelId AppSettingsService.newItemsChannelId,
) )
.setContentTitle(context.getString(R.string.new_items_notification_title)) .setContentTitle(context.getString(R.string.new_items_notification_title))
.setContentText( .setContentText(
context.getString( context.getString(
R.string.new_items_notification_text, R.string.new_items_notification_text,
newSize newSize,
) ),
) )
.setPriority(PRIORITY_DEFAULT) .setPriority(PRIORITY_DEFAULT)
.setChannelId(AppSettingsService.newItemsChannelId) .setChannelId(AppSettingsService.newItemsChannelId)
@ -114,4 +113,4 @@ class LoadingWorker(val context: Context, params: WorkerParameters) : Worker(con
} }
} }
} }
} }

View File

@ -52,12 +52,10 @@ import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.instance import org.kodein.di.instance
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.SocketTimeoutException
import java.net.URL import java.net.URL
import java.util.* import java.util.*
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
private const val IMAGE_JPG = "image/jpg" private const val IMAGE_JPG = "image/jpg"
class ArticleFragment : Fragment(), DIAware { class ArticleFragment : Fragment(), DIAware {
@ -84,7 +82,6 @@ class ArticleFragment : Fragment(), DIAware {
private val mercuryApi: MercuryApi by instance() private val mercuryApi: MercuryApi by instance()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -96,7 +93,7 @@ class ArticleFragment : Fragment(), DIAware {
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
): View { ): View {
try { try {
binding = FragmentArticleBinding.inflate(inflater, container, false) binding = FragmentArticleBinding.inflate(inflater, container, false)
@ -105,7 +102,12 @@ class ArticleFragment : Fragment(), DIAware {
contentText = item.content contentText = item.content
contentTitle = item.title.getHtmlDecoded() contentTitle = item.title.getHtmlDecoded()
contentImage = item.getThumbnail(repository.baseUrl) contentImage = item.getThumbnail(repository.baseUrl)
contentSource = item.sourceAuthorAndDate() contentSource = try {
item.sourceAuthorAndDate()
} catch (e: Exception) {
e.sendSilentlyWithAcraWithName("Article Fragment parse date")
item.sourceAuthorOnly()
}
allImages = item.getImages() allImages = item.getImages()
fontSize = appSettingsService.getFontSize() fontSize = appSettingsService.getFontSize()
@ -146,9 +148,8 @@ class ArticleFragment : Fragment(), DIAware {
if (floatingToolbar.isShowing) floatingToolbar.hide() else fab.show() if (floatingToolbar.isShowing) floatingToolbar.hide() else fab.show()
} }
} }
} },
) )
} catch (e: InflateException) { } catch (e: InflateException) {
e.sendSilentlyWithAcraWithName("webview not available") e.sendSilentlyWithAcraWithName("webview not available")
if (context != null) { if (context != null) {
@ -156,7 +157,7 @@ class ArticleFragment : Fragment(), DIAware {
.setMessage(requireContext().getString(R.string.webview_dialog_issue_message)) .setMessage(requireContext().getString(R.string.webview_dialog_issue_message))
.setTitle(requireContext().getString(R.string.webview_dialog_issue_title)) .setTitle(requireContext().getString(R.string.webview_dialog_issue_title))
.setPositiveButton( .setPositiveButton(
android.R.string.ok android.R.string.ok,
) { _, _ -> ) { _, _ ->
appSettingsService.disableArticleViewer() appSettingsService.disableArticleViewer()
requireActivity().finish() requireActivity().finish()
@ -211,29 +212,30 @@ class ArticleFragment : Fragment(), DIAware {
when (item.itemId) { when (item.itemId) {
R.id.share_action -> requireActivity().shareLink(url, contentTitle) R.id.share_action -> requireActivity().shareLink(url, contentTitle)
R.id.open_action -> requireActivity().openInBrowserAsNewTask(this@ArticleFragment.item) R.id.open_action -> requireActivity().openInBrowserAsNewTask(this@ArticleFragment.item)
R.id.unread_action -> if (context != null) { R.id.unread_action ->
if (this@ArticleFragment.item.unread) { if (context != null) {
CoroutineScope(Dispatchers.IO).launch { if (this@ArticleFragment.item.unread) {
repository.markAsRead(this@ArticleFragment.item) CoroutineScope(Dispatchers.IO).launch {
repository.markAsRead(this@ArticleFragment.item)
}
this@ArticleFragment.item.unread = false
Toast.makeText(
context,
R.string.marked_as_read,
Toast.LENGTH_LONG,
).show()
} else {
CoroutineScope(Dispatchers.IO).launch {
repository.unmarkAsRead(this@ArticleFragment.item)
}
this@ArticleFragment.item.unread = true
Toast.makeText(
context,
R.string.marked_as_unread,
Toast.LENGTH_LONG,
).show()
} }
this@ArticleFragment.item.unread = false
Toast.makeText(
context,
R.string.marked_as_read,
Toast.LENGTH_LONG
).show()
} else {
CoroutineScope(Dispatchers.IO).launch {
repository.unmarkAsRead(this@ArticleFragment.item)
}
this@ArticleFragment.item.unread = true
Toast.makeText(
context,
R.string.marked_as_unread,
Toast.LENGTH_LONG
).show()
} }
}
else -> Unit else -> Unit
} }
} }
@ -241,17 +243,18 @@ class ArticleFragment : Fragment(), DIAware {
override fun onItemLongClick(item: MenuItem?) { override fun onItemLongClick(item: MenuItem?) {
// We do nothing // We do nothing
} }
} },
) )
return floatingToolbar return floatingToolbar
} }
private fun refreshAlignment() { private fun refreshAlignment() {
textAlignment = when (appSettingsService.getActiveAllignment()) { textAlignment =
1 -> "justify" when (appSettingsService.getActiveAllignment()) {
2 -> "left" 1 -> "justify"
else -> "justify" 2 -> "left"
} else -> "justify"
}
} }
private fun getContentFromMercury() { private fun getContentFromMercury() {
@ -265,10 +268,7 @@ class ArticleFragment : Fragment(), DIAware {
} else { } else {
openInBrowserAfterFailing() openInBrowserAfterFailing()
} }
} catch (e: SocketTimeoutException) {
openInBrowserAfterFailing()
} catch (e: Exception) { } catch (e: Exception) {
e.sendSilentlyWithAcraWithName("getContentFromMercury > $url")
openInBrowserAfterFailing() openInBrowserAfterFailing()
} }
} }
@ -302,7 +302,7 @@ class ArticleFragment : Fragment(), DIAware {
.with(requireContext()) .with(requireContext())
.asBitmap() .asBitmap()
.load( .load(
lead_image_url lead_image_url,
) )
.apply(RequestOptions.fitCenterTransform()) .apply(RequestOptions.fitCenterTransform())
.into(binding.imageView) .into(binding.imageView)
@ -312,67 +312,75 @@ class ArticleFragment : Fragment(), DIAware {
} }
private fun handleImageLoading() { private fun handleImageLoading() {
binding.webcontent.webViewClient = object : WebViewClient() { binding.webcontent.webViewClient =
@Deprecated("Deprecated in Java") object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, url: String): Boolean { @Deprecated("Deprecated in Java")
return if (context != null && url.isUrlValid() && binding.webcontent.hitTestResult.type != WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { override fun shouldOverrideUrlLoading(
try { view: WebView?,
requireContext().startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) url: String,
} catch (e: ActivityNotFoundException) { ): Boolean {
e.sendSilentlyWithAcraWithName("activityNotFound > $url") return if (context != null && url.isUrlValid() && binding.webcontent.hitTestResult.type != WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
} try {
true requireContext().startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} else { } catch (e: ActivityNotFoundException) {
false e.sendSilentlyWithAcraWithName("activityNotFound > $url")
} }
} true
} else {
@Deprecated("Deprecated in Java") false
override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL)
if (url.lowercase(Locale.US).contains(".jpg") || url.lowercase(Locale.US)
.contains(".jpeg")
) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.JPEG)
)
} catch (e: ExecutionException) {
// Do nothing
}
} else if (url.lowercase(Locale.US).contains(".png")) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.PNG)
)
} catch (e: ExecutionException) {
// Do nothing
}
} else if (url.lowercase(Locale.US).contains(".webp")) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.WEBP)
)
} catch (e: ExecutionException) {
// Do nothing
} }
} }
return super.shouldInterceptRequest(view, url) @Deprecated("Deprecated in Java")
override fun shouldInterceptRequest(
view: WebView,
url: String,
): WebResourceResponse? {
val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL)
if (url.lowercase(Locale.US).contains(".jpg") ||
url.lowercase(Locale.US)
.contains(".jpeg")
) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.JPEG),
)
} catch (e: ExecutionException) {
// Do nothing
}
} else if (url.lowercase(Locale.US).contains(".png")) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.PNG),
)
} catch (e: ExecutionException) {
// Do nothing
}
} else if (url.lowercase(Locale.US).contains(".webp")) {
try {
val image =
Glide.with(view).asBitmap().apply(glideOptions).load(url).submit().get()
return WebResourceResponse(
IMAGE_JPG,
"UTF-8",
getBitmapInputStream(image, Bitmap.CompressFormat.WEBP),
)
} catch (e: ExecutionException) {
// Do nothing
}
}
return super.shouldInterceptRequest(view, url)
}
} }
}
} }
private fun htmlToWebview() { private fun htmlToWebview() {
@ -380,7 +388,6 @@ class ArticleFragment : Fragment(), DIAware {
val attrs: IntArray = intArrayOf(android.R.attr.fontFamily) val attrs: IntArray = intArrayOf(android.R.attr.fontFamily)
val a: TypedArray = requireContext().obtainStyledAttributes(resId, attrs) val a: TypedArray = requireContext().obtainStyledAttributes(resId, attrs)
binding.webcontent.settings.standardFontFamily = a.getString(0) binding.webcontent.settings.standardFontFamily = a.getString(0)
binding.webcontent.visibility = View.VISIBLE binding.webcontent.visibility = View.VISIBLE
@ -397,11 +404,14 @@ class ArticleFragment : Fragment(), DIAware {
handleImageLoading() handleImageLoading()
val gestureDetector = val gestureDetector =
GestureDetector(activity, object : GestureDetector.SimpleOnGestureListener() { GestureDetector(
override fun onSingleTapUp(e: MotionEvent): Boolean { activity,
return performClick() object : GestureDetector.SimpleOnGestureListener() {
} override fun onSingleTapUp(e: MotionEvent): Boolean {
}) return performClick()
}
},
)
binding.webcontent.setOnTouchListener { _, event -> gestureDetector.onTouchEvent(event) } binding.webcontent.setOnTouchListener { _, event -> gestureDetector.onTouchEvent(event) }
@ -417,29 +427,31 @@ class ArticleFragment : Fragment(), DIAware {
e.sendSilentlyWithAcraWithName("htmlToWebview > $url") e.sendSilentlyWithAcraWithName("htmlToWebview > $url")
} }
val fontName = when (font) { val fontName =
getString(R.string.open_sans_font_id) -> "Open Sans" when (font) {
getString(R.string.roboto_font_id) -> "Roboto" getString(R.string.open_sans_font_id) -> "Open Sans"
getString(R.string.source_code_pro_font_id) -> "Source Code Pro" getString(R.string.roboto_font_id) -> "Roboto"
else -> "" getString(R.string.source_code_pro_font_id) -> "Source Code Pro"
} else -> ""
}
val fontLinkAndStyle = if (font.isNotEmpty()) { val fontLinkAndStyle =
"""<link href="https://fonts.googleapis.com/css?family=${ if (font.isNotEmpty()) {
fontName.replace( """<link href="https://fonts.googleapis.com/css?family=${
" ", fontName.replace(
"+" " ",
) "+",
}" rel="stylesheet"> )
}" rel="stylesheet">
|<style> |<style>
| * { | * {
| font-family: '$fontName'; | font-family: '$fontName';
| } | }
|</style> |</style>
""".trimMargin() """.trimMargin()
} else { } else {
"" ""
} }
binding.webcontent.loadDataWithBaseURL( binding.webcontent.loadDataWithBaseURL(
baseUrl, baseUrl,
@ -457,7 +469,7 @@ class ArticleFragment : Fragment(), DIAware {
| color: ${ | color: ${
String.format( String.format(
"#%06X", "#%06X",
0xFFFFFF and resources.getColor(R.color.colorAccent) 0xFFFFFF and resources.getColor(R.color.colorAccent),
) )
} !important; } !important;
| } | }
@ -473,7 +485,7 @@ class ArticleFragment : Fragment(), DIAware {
| background-color: ${ | background-color: ${
String.format( String.format(
"#%06X", "#%06X",
0xFFFFFF and colorSurface.data 0xFFFFFF and colorSurface.data,
) )
}; };
| } | }
@ -481,13 +493,13 @@ class ArticleFragment : Fragment(), DIAware {
| background-color: ${ | background-color: ${
String.format( String.format(
"#%06X", "#%06X",
0xFFFFFF and colorSurface.data 0xFFFFFF and colorSurface.data,
) )
} !important; } !important;
| border-color: ${ | border-color: ${
String.format( String.format(
"#%06X", "#%06X",
0xFFFFFF and colorSurface.data 0xFFFFFF and colorSurface.data,
) )
} !important; } !important;
| padding: 0 !important; | padding: 0 !important;
@ -502,7 +514,7 @@ class ArticleFragment : Fragment(), DIAware {
| background-color: ${ | background-color: ${
String.format( String.format(
"#%06X", "#%06X",
0xFFFFFF and colorSurface.data 0xFFFFFF and colorSurface.data,
) )
}; };
| } | }
@ -511,10 +523,11 @@ class ArticleFragment : Fragment(), DIAware {
|</head> |</head>
|<body> |<body>
| $contentText | $contentText
|</body>""".trimMargin(), |</body>
""".trimMargin(),
"text/html", "text/html",
"utf-8", "utf-8",
null null,
) )
} }
} }
@ -535,16 +548,13 @@ class ArticleFragment : Fragment(), DIAware {
requireContext().openInBrowserAsNewTask(this@ArticleFragment.item) requireContext().openInBrowserAsNewTask(this@ArticleFragment.item)
} else { } else {
Exception("openInBrowserAfterFailing context is null").sendSilentlyWithAcraWithName("openInBrowserAfterFailing > $context") Exception("openInBrowserAfterFailing context is null").sendSilentlyWithAcraWithName("openInBrowserAfterFailing > $context")
} }
} }
companion object { companion object {
private const val ARG_ITEMS = "items" private const val ARG_ITEMS = "items"
fun newInstance( fun newInstance(item: SelfossModel.Item): ArticleFragment {
item: SelfossModel.Item
): ArticleFragment {
val fragment = ArticleFragment() val fragment = ArticleFragment()
val args = Bundle() val args = Bundle()
args.putParcelable(ARG_ITEMS, item.toParcelable()) args.putParcelable(ARG_ITEMS, item.toParcelable())
@ -554,10 +564,11 @@ class ArticleFragment : Fragment(), DIAware {
} }
fun performClick(): Boolean { fun performClick(): Boolean {
if (allImages != null && (binding.webcontent.hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE || if (allImages != null && (
binding.webcontent.hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) binding.webcontent.hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
binding.webcontent.hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
)
) { ) {
val position: Int = allImages.indexOf(binding.webcontent.hitTestResult.extra) val position: Int = allImages.indexOf(binding.webcontent.hitTestResult.extra)
val intent = Intent(activity, ImageActivity::class.java) val intent = Intent(activity, ImageActivity::class.java)
@ -568,6 +579,4 @@ class ArticleFragment : Fragment(), DIAware {
} }
return false return false
} }
} }

View File

@ -17,6 +17,7 @@ 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.sendSilentlyWithAcraWithName import bou.amine.apps.readerforselfossv2.android.sendSilentlyWithAcraWithName
import bou.amine.apps.readerforselfossv2.repository.Repository import bou.amine.apps.readerforselfossv2.repository.Repository
import bou.amine.apps.readerforselfossv2.utils.getColorHexCode
import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded import bou.amine.apps.readerforselfossv2.utils.getHtmlDecoded
import bou.amine.apps.readerforselfossv2.utils.getIcon import bou.amine.apps.readerforselfossv2.utils.getIcon
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -32,9 +33,7 @@ import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.instance import org.kodein.di.instance
class FilterSheetFragment : BottomSheetDialogFragment(), DIAware { class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
private lateinit var binding: FilterFragmentBinding private lateinit var binding: FilterFragmentBinding
override val di: DI by closestDI() override val di: DI by closestDI()
private val repository: Repository by instance() private val repository: Repository by instance()
@ -44,18 +43,17 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
): View { ): View {
binding = binding =
FilterFragmentBinding.inflate( FilterFragmentBinding.inflate(
inflater, inflater,
container, container,
false false,
) )
val context: Context? = context val context: Context? = context
if (context == null) { if (context == null) {
dismiss() dismiss()
Exception("FilterSheetFragment context is null").sendSilentlyWithAcraWithName("FilterSheetFragment > onCreateView") Exception("FilterSheetFragment context is null").sendSilentlyWithAcraWithName("FilterSheetFragment > onCreateView")
@ -77,9 +75,7 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
return binding.root return binding.root
} }
private suspend fun handleSourceChips( private suspend fun handleSourceChips(context: Context) {
context: Context
) {
val sourceGroup = binding.sourcesGroup val sourceGroup = binding.sourcesGroup
repository.getSourcesDetailsOrStats().forEachIndexed { _, source -> repository.getSourcesDetailsOrStats().forEachIndexed { _, source ->
@ -88,19 +84,20 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
Glide.with(context) Glide.with(context)
.load(source.getIcon(repository.baseUrl)) .load(source.getIcon(repository.baseUrl))
.into(object : ViewTarget<Chip?, Drawable?>(c) { .into(
override fun onResourceReady( object : ViewTarget<Chip?, Drawable?>(c) {
resource: Drawable, override fun onResourceReady(
transition: Transition<in Drawable?>? resource: Drawable,
) { transition: Transition<in Drawable?>?,
try { ) {
c.chipIcon = resource try {
} catch (e: Exception) { c.chipIcon = resource
e.sendSilentlyWithAcraWithName("sources > onResourceReady") } catch (e: Exception) {
e.sendSilentlyWithAcraWithName("sources > onResourceReady")
}
} }
} },
)
})
c.text = source.title.getHtmlDecoded() c.text = source.title.getHtmlDecoded()
@ -121,7 +118,6 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
repository.setTagFilter(null) repository.setTagFilter(null)
} }
if (repository.sourceFilter.value?.equals(source) == true) { if (repository.sourceFilter.value?.equals(source) == true) {
c.isCloseIconVisible = true c.isCloseIconVisible = true
selectedChip = c selectedChip = c
@ -137,9 +133,7 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
} }
} }
private suspend fun handleTagChips( private suspend fun handleTagChips(context: Context) {
context: Context,
) {
val tagGroup = binding.tagsGroup val tagGroup = binding.tagsGroup
val tags = repository.getTags() val tags = repository.getTags()
@ -152,12 +146,13 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
if (tag.color.isNotEmpty()) { if (tag.color.isNotEmpty()) {
try { try {
val gd = GradientDrawable() val gd = GradientDrawable()
val gdColor = try { val gdColor =
Color.parseColor(tag.color) try {
} catch (e: IllegalArgumentException) { Color.parseColor(tag.getColorHexCode())
e.sendSilentlyWithAcraWithName("color issue " + tag.color) } catch (e: IllegalArgumentException) {
resources.getColor(R.color.colorPrimary) e.sendSilentlyWithAcraWithName("color issue " + tag.color + " / " + tag.getColorHexCode())
} resources.getColor(R.color.colorPrimary)
}
gd.setColor(gdColor) gd.setColor(gdColor)
gd.shape = GradientDrawable.RECTANGLE gd.shape = GradientDrawable.RECTANGLE
gd.setSize(30, 30) gd.setSize(30, 30)
@ -197,6 +192,4 @@ class FilterSheetFragment : BottomSheetDialogFragment(), DIAware {
companion object { companion object {
const val TAG = "FilterModalBottomSheet" const val TAG = "FilterModalBottomSheet"
} }
}
}

View File

@ -11,8 +11,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
class ImageFragment : Fragment() { class ImageFragment : Fragment() {
private lateinit var imageUrl: String
private lateinit var imageUrl : String
private val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL) private val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL)
private var _binding: FragmentImageBinding? = null private var _binding: FragmentImageBinding? = null
private val binding get() = _binding private val binding get() = _binding
@ -23,16 +22,20 @@ class ImageFragment : Fragment() {
imageUrl = requireArguments().getString("imageUrl")!! imageUrl = requireArguments().getString("imageUrl")!!
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
_binding = FragmentImageBinding.inflate(inflater, container, false) _binding = FragmentImageBinding.inflate(inflater, container, false)
val view = binding?.root val view = binding?.root
binding!!.photoView.visibility = View.VISIBLE binding!!.photoView.visibility = View.VISIBLE
Glide.with(requireActivity()) Glide.with(requireActivity())
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(imageUrl) .load(imageUrl)
.into(binding!!.photoView) .into(binding!!.photoView)
return view return view
} }
@ -45,9 +48,7 @@ class ImageFragment : Fragment() {
companion object { companion object {
private const val ARG_IMAGE = "imageUrl" private const val ARG_IMAGE = "imageUrl"
fun newInstance( fun newInstance(imageUrl: String): ImageFragment {
imageUrl : String
): ImageFragment {
val fragment = ImageFragment() val fragment = ImageFragment()
val args = Bundle() val args = Bundle()
args.putString(ARG_IMAGE, imageUrl) args.putString(ARG_IMAGE, imageUrl)
@ -55,4 +56,4 @@ class ImageFragment : Fragment() {
return fragment return fragment
} }
} }
} }

View File

@ -9,21 +9,20 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
fun SelfossModel.Item.preloadImages(context: Context) : Boolean { fun SelfossModel.Item.preloadImages(context: Context): Boolean {
val imageUrls = this.getImages() val imageUrls = this.getImages()
val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL).timeout(10000) val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL).timeout(10000)
try { try {
for (url in imageUrls) { for (url in imageUrls) {
if ( URLUtil.isValidUrl(url)) { if (URLUtil.isValidUrl(url)) {
Glide.with(context).asBitmap() Glide.with(context).asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(url).submit() .load(url).submit()
} }
} }
} catch (e : Error) { } catch (e: Error) {
e.sendSilentlyWithAcraWithName("preloadImages") e.sendSilentlyWithAcraWithName("preloadImages")
return false return false
} }
@ -41,4 +40,4 @@ fun String.toTextDrawableString(): String {
} }
} }
return textDrawable.toString() return textDrawable.toString()
} }

View File

@ -4,7 +4,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import bou.amine.apps.readerforselfossv2.model.SelfossModel import bou.amine.apps.readerforselfossv2.model.SelfossModel
fun SelfossModel.Item.toParcelable() : ParecelableItem = fun SelfossModel.Item.toParcelable(): ParecelableItem =
ParecelableItem( ParecelableItem(
this.id, this.id,
this.datetime, this.datetime,
@ -17,9 +17,10 @@ fun SelfossModel.Item.toParcelable() : ParecelableItem =
this.link, this.link,
this.sourcetitle, this.sourcetitle,
this.tags.joinToString(","), this.tags.joinToString(","),
this.author this.author,
) )
fun ParecelableItem.toModel() : SelfossModel.Item =
fun ParecelableItem.toModel(): SelfossModel.Item =
SelfossModel.Item( SelfossModel.Item(
this.id, this.id,
this.datetime, this.datetime,
@ -32,8 +33,9 @@ fun ParecelableItem.toModel() : SelfossModel.Item =
this.link, this.link,
this.sourcetitle, this.sourcetitle,
this.tags.split(","), this.tags.split(","),
this.author this.author,
) )
data class ParecelableItem( data class ParecelableItem(
val id: Int, val id: Int,
val datetime: String, val datetime: String,
@ -46,15 +48,16 @@ data class ParecelableItem(
val link: String, val link: String,
val sourcetitle: String, val sourcetitle: String,
val tags: String, val tags: String,
val author: String? val author: String?,
) : Parcelable { ) : Parcelable {
companion object { companion object {
@JvmField @JvmField
val CREATOR: Parcelable.Creator<ParecelableItem> = object : Parcelable.Creator<ParecelableItem> { val CREATOR: Parcelable.Creator<ParecelableItem> =
override fun createFromParcel(source: Parcel): ParecelableItem = ParecelableItem(source) object : Parcelable.Creator<ParecelableItem> {
override fun newArray(size: Int): Array<ParecelableItem?> = arrayOfNulls(size) override fun createFromParcel(source: Parcel): ParecelableItem = ParecelableItem(source)
}
override fun newArray(size: Int): Array<ParecelableItem?> = arrayOfNulls(size)
}
} }
constructor(source: Parcel) : this( constructor(source: Parcel) : this(
@ -69,12 +72,15 @@ data class ParecelableItem(
link = source.readString().orEmpty(), link = source.readString().orEmpty(),
sourcetitle = source.readString().orEmpty(), sourcetitle = source.readString().orEmpty(),
tags = source.readString().orEmpty(), tags = source.readString().orEmpty(),
author = source.readString().orEmpty() author = source.readString().orEmpty(),
) )
override fun describeContents() = 0 override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(
dest: Parcel,
flags: Int,
) {
dest.writeInt(id) dest.writeInt(id)
dest.writeString(datetime) dest.writeString(datetime)
dest.writeString(title) dest.writeString(title)
@ -88,4 +94,4 @@ data class ParecelableItem(
dest.writeString(tags) dest.writeString(tags)
dest.writeString(author) dest.writeString(author)
} }
} }

View File

@ -24,8 +24,10 @@ import org.kodein.di.android.closestDI
private const val TITLE_TAG = "settingsActivityTitle" private const val TITLE_TAG = "settingsActivityTitle"
class SettingsActivity : AppCompatActivity(), class SettingsActivity :
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, DIAware { AppCompatActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
DIAware {
override val di by closestDI() override val di by closestDI()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -35,9 +37,9 @@ class SettingsActivity : AppCompatActivity(),
setContentView(binding.root) setContentView(binding.root)
if (savedInstanceState == null) { if (savedInstanceState == null) {
supportFragmentManager supportFragmentManager
.beginTransaction() .beginTransaction()
.replace(R.id.settings, MainPreferenceFragment()) .replace(R.id.settings, MainPreferenceFragment())
.commit() .commit()
} else { } else {
title = savedInstanceState.getCharSequence(TITLE_TAG) title = savedInstanceState.getCharSequence(TITLE_TAG)
} }
@ -71,57 +73,67 @@ class SettingsActivity : AppCompatActivity(),
} }
override fun onPreferenceStartFragment( override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat, caller: PreferenceFragmentCompat,
pref: Preference pref: Preference,
): Boolean { ): Boolean {
// Instantiate the new Fragment // Instantiate the new Fragment
val args = pref.extras val args = pref.extras
val fragment = supportFragmentManager.fragmentFactory.instantiate( val fragment =
supportFragmentManager.fragmentFactory.instantiate(
classLoader, classLoader,
pref.fragment.toString() pref.fragment.toString(),
).apply { ).apply {
arguments = args arguments = args
setTargetFragment(caller, 0) setTargetFragment(caller, 0)
} }
// Replace the existing Fragment with the new Fragment // Replace the existing Fragment with the new Fragment
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.settings, fragment) .replace(R.id.settings, fragment)
.addToBackStack(null) .addToBackStack(null)
.commit() .commit()
title = pref.title title = pref.title
supportActionBar?.title = title supportActionBar?.title = title
return true return true
} }
class MainPreferenceFragment : PreferenceFragmentCompat() { class MainPreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_main, rootKey) setPreferencesFromResource(R.xml.pref_main, rootKey)
preferenceManager.findPreference<Preference>("currentMode")?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> preferenceManager.findPreference<Preference>("currentMode")?.onPreferenceChangeListener =
AppCompatDelegate.setDefaultNightMode(newValue.toString().toInt()) // ListPreference Only takes string-arrays ¯\_(ツ)_/¯ Preference.OnPreferenceChangeListener { _, newValue ->
true AppCompatDelegate.setDefaultNightMode(newValue.toString().toInt()) // ListPreference Only takes string-arrays ¯\_(ツ)_/¯
} true
}
preferenceManager.findPreference<Preference>("action_about")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
context?.let { preferenceManager.findPreference<Preference>("action_about")?.onPreferenceClickListener =
LibsBuilder() Preference.OnPreferenceClickListener { _ ->
.withAboutIconShown(true) context?.let {
.withAboutVersionShown(true) LibsBuilder()
.start(it) .withAboutIconShown(true)
.withAboutVersionShown(true)
.start(it)
}
true
} }
true
}
} }
} }
class GeneralPreferenceFragment : PreferenceFragmentCompat() { class GeneralPreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_general, rootKey) setPreferencesFromResource(R.xml.pref_general, rootKey)
val editTextPreference = preferenceManager.findPreference<EditTextPreference>("prefer_api_items_number") val editTextPreference = preferenceManager.findPreference<EditTextPreference>("prefer_api_items_number")
editTextPreference?.setOnBindEditTextListener { editText -> editTextPreference?.setOnBindEditTextListener { editText ->
editText.inputType = InputType.TYPE_CLASS_NUMBER editText.inputType = InputType.TYPE_CLASS_NUMBER
editText.filters = arrayOf( editText.filters =
arrayOf(
InputFilter { source, _, _, dest, _, _ -> InputFilter { source, _, _, dest, _, _ ->
try { try {
val input: Int = (dest.toString() + source.toString()).toInt() val input: Int = (dest.toString() + source.toString()).toInt()
@ -131,35 +143,53 @@ class SettingsActivity : AppCompatActivity(),
Toast.makeText(activity, R.string.items_number_should_be_number, Toast.LENGTH_LONG).show() Toast.makeText(activity, R.string.items_number_should_be_number, Toast.LENGTH_LONG).show()
} }
"" ""
} },
) )
} }
} }
} }
class ArticleViewerPreferenceFragment : PreferenceFragmentCompat() { class ArticleViewerPreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_viewer, rootKey) setPreferencesFromResource(R.xml.pref_viewer, rootKey)
val fontSize = preferenceManager.findPreference<EditTextPreference>("reader_font_size") val fontSize = preferenceManager.findPreference<EditTextPreference>("reader_font_size")
fontSize?.setOnBindEditTextListener { editText -> fontSize?.setOnBindEditTextListener { editText ->
editText.inputType = InputType.TYPE_CLASS_NUMBER editText.inputType = InputType.TYPE_CLASS_NUMBER
editText.addTextChangedListener { object : TextWatcher { editText.addTextChangedListener {
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) { object : TextWatcher {
// We do nothing override fun beforeTextChanged(
} charSequence: CharSequence,
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) { i: Int,
// We do nothing i1: Int,
} i2: Int,
override fun afterTextChanged(editable: Editable) { ) {
try { // We do nothing
editText.textSize = editable.toString().toInt().toFloat() }
} catch (e: NumberFormatException) {
e.sendSilentlyWithAcraWithName("ArticleViewerPreferenceFragment > afterTextChanged") override fun onTextChanged(
charSequence: CharSequence,
i: Int,
i1: Int,
i2: Int,
) {
// We do nothing
}
override fun afterTextChanged(editable: Editable) {
try {
editText.textSize = editable.toString().toInt().toFloat()
} catch (e: NumberFormatException) {
e.sendSilentlyWithAcraWithName("ArticleViewerPreferenceFragment > afterTextChanged")
}
} }
} }
} } }
editText.filters = arrayOf( editText.filters =
arrayOf(
InputFilter { source, _, _, dest, _, _ -> InputFilter { source, _, _, dest, _, _ ->
try { try {
val input = (dest.toString() + source.toString()).toInt() val input = (dest.toString() + source.toString()).toInt()
@ -168,26 +198,33 @@ class SettingsActivity : AppCompatActivity(),
nfe.sendSilentlyWithAcraWithName("ArticleViewerPreferenceFragment > filters") nfe.sendSilentlyWithAcraWithName("ArticleViewerPreferenceFragment > filters")
} }
"" ""
} },
) )
} }
} }
} }
class OfflinePreferenceFragment : PreferenceFragmentCompat() { class OfflinePreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_offline, rootKey) setPreferencesFromResource(R.xml.pref_offline, rootKey)
} }
} }
class ThemePreferenceFragment : PreferenceFragmentCompat() { class ThemePreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_theme, rootKey) setPreferencesFromResource(R.xml.pref_theme, rootKey)
preferenceManager.findPreference<Preference>("currentMode")?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> preferenceManager.findPreference<Preference>("currentMode")?.onPreferenceChangeListener =
AppCompatDelegate.setDefaultNightMode(newValue.toString().toInt()) // ListPreference Only takes string-arrays ¯\_(ツ)_/¯ Preference.OnPreferenceChangeListener { _, newValue ->
true AppCompatDelegate.setDefaultNightMode(newValue.toString().toInt()) // ListPreference Only takes string-arrays ¯\_(ツ)_/¯
} true
}
} }
} }
@ -197,29 +234,38 @@ class SettingsActivity : AppCompatActivity(),
startActivity(browserIntent) startActivity(browserIntent)
} }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_links, rootKey) setPreferencesFromResource(R.xml.pref_links, rootKey)
preferenceManager.findPreference<Preference>("trackerLink")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { preferenceManager.findPreference<Preference>("trackerLink")?.onPreferenceClickListener =
openUrl(Uri.parse(AppSettingsService.trackerUrl)) Preference.OnPreferenceClickListener {
true openUrl(Uri.parse(AppSettingsService.trackerUrl))
} true
}
preferenceManager.findPreference<Preference>("sourceLink")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { preferenceManager.findPreference<Preference>("sourceLink")?.onPreferenceClickListener =
openUrl(Uri.parse(AppSettingsService.sourceUrl)) Preference.OnPreferenceClickListener {
false openUrl(Uri.parse(AppSettingsService.sourceUrl))
} false
}
preferenceManager.findPreference<Preference>("translation")?.onPreferenceClickListener = Preference.OnPreferenceClickListener { preferenceManager.findPreference<Preference>("translation")?.onPreferenceClickListener =
openUrl(Uri.parse(AppSettingsService.translationUrl)) Preference.OnPreferenceClickListener {
false openUrl(Uri.parse(AppSettingsService.translationUrl))
} false
}
} }
} }
class ExperimentalPreferenceFragment : PreferenceFragmentCompat() { class ExperimentalPreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
setPreferencesFromResource(R.xml.pref_experimental, rootKey) setPreferencesFromResource(R.xml.pref_experimental, rootKey)
} }
} }
} }

View File

@ -0,0 +1,21 @@
package bou.amine.apps.readerforselfossv2.android.testing
import androidx.test.espresso.idling.CountingIdlingResource
object CountingIdlingResourceSingleton {
private const val RESOURCE = "GLOBAL"
@JvmField
val countingIdlingResource = CountingIdlingResource(RESOURCE)
fun increment() {
countingIdlingResource.increment()
}
fun decrement() {
if (!countingIdlingResource.isIdleNow) {
countingIdlingResource.decrement()
}
}
}

View File

@ -0,0 +1,19 @@
package bou.amine.apps.readerforselfossv2.android.testing
import android.os.Build
class TestingHelper {
fun isUnitTest(): Boolean {
var device = Build.DEVICE
var product = Build.PRODUCT
if (device == null) {
device = ""
}
if (product == null) {
product = ""
}
return device == "robolectric" && product == "robolectric"
}
}

View File

@ -5,7 +5,10 @@ import android.content.Intent
import bou.amine.apps.readerforselfossv2.android.R import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.utils.toStringUriWithHttp import bou.amine.apps.readerforselfossv2.utils.toStringUriWithHttp
fun Context.shareLink(itemUrl: String, itemTitle: String) { fun Context.shareLink(
itemUrl: String,
itemTitle: String,
) {
val sendIntent = Intent() val sendIntent = Intent()
sendIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK sendIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
sendIntent.action = Intent.ACTION_SEND sendIntent.action = Intent.ACTION_SEND
@ -15,7 +18,7 @@ fun Context.shareLink(itemUrl: String, itemTitle: String) {
startActivity( startActivity(
Intent.createChooser( Intent.createChooser(
sendIntent, sendIntent,
getString(R.string.share) getString(R.string.share),
).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
) )
} }

View File

@ -12,51 +12,54 @@ import bou.amine.apps.readerforselfossv2.android.model.toTextDrawableString
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import kotlin.math.abs import kotlin.math.abs
class CircleImageView @JvmOverloads constructor( class CircleImageView
context: Context, @JvmOverloads
attrs: AttributeSet? = null, constructor(
defStyleAttr: Int = 0 context: Context,
) : RelativeLayout(context, attrs, defStyleAttr) { attrs: AttributeSet? = null,
val view: View defStyleAttr: Int = 0,
val imageView: ShapeableImageView ) : RelativeLayout(context, attrs, defStyleAttr) {
val textView: TextView val view: View
val imageView: ShapeableImageView
val textView: TextView
private val colorScheme = listOf( private val colorScheme =
-0x1a8c8d, listOf(
-0xf9d6e, -0x1a8c8d,
-0x459738, -0xf9d6e,
-0x6a8a33, -0x459738,
-0x867935, -0x6a8a33,
-0x9b4a0a, -0x867935,
-0xb03c09, -0x9b4a0a,
-0xb22f1f, -0xb03c09,
-0xb24954, -0xb22f1f,
-0x7e387c, -0xb24954,
-0x512a7f, -0x7e387c,
-0x759b, -0x512a7f,
-0x2b1ea9, -0x759b,
-0x2ab1, -0x2b1ea9,
-0x48b3, -0x2ab1,
-0x5e7781, -0x48b3,
-0x6f5b52 -0x5e7781,
) -0x6f5b52,
)
init { init {
view = LayoutInflater.from(context).inflate(R.layout.circle_image_view, this, true) view = LayoutInflater.from(context).inflate(R.layout.circle_image_view, this, true)
imageView = view.findViewById(R.id.circleImage) imageView = view.findViewById(R.id.circleImage)
textView = view.findViewById(R.id.circleText) textView = view.findViewById(R.id.circleText)
}
fun setBackgroundAndText(text: String) {
val circleDrawable = GradientDrawable()
val color = colorFromIdentifier(text)
circleDrawable.setColor(color)
imageView.setImageDrawable(circleDrawable)
textView.text = text.toTextDrawableString()
}
private fun colorFromIdentifier(key: String): Int {
return colorScheme[abs(key.hashCode()) % colorScheme.size]
}
} }
fun setBackgroundAndText(text: String) {
val circleDrawable = GradientDrawable()
val color = colorFromIdentifier(text)
circleDrawable.setColor(color)
imageView.setImageDrawable(circleDrawable)
textView.text = text.toTextDrawableString()
}
private fun colorFromIdentifier(key: String): Int {
return colorScheme[abs(key.hashCode()) % colorScheme.size]
}
}

View File

@ -21,14 +21,13 @@ fun Context.openItemUrl(
currentItem: Int, currentItem: Int,
linkDecoded: String, linkDecoded: String,
articleViewer: Boolean, articleViewer: Boolean,
app: Activity app: Activity,
) { ) {
if (!linkDecoded.isUrlValid()) { if (!linkDecoded.isUrlValid()) {
Toast.makeText( Toast.makeText(
this, this,
this.getString(R.string.cant_open_invalid_url), this.getString(R.string.cant_open_invalid_url),
Toast.LENGTH_LONG Toast.LENGTH_LONG,
).show() ).show()
} else { } else {
if (articleViewer) { if (articleViewer) {
@ -44,8 +43,7 @@ fun Context.openItemUrl(
} }
} }
fun String.isUrlValid(): Boolean = fun String.isUrlValid(): Boolean = this.toHttpUrlOrNull() != null && Patterns.WEB_URL.matcher(this).matches()
this.toHttpUrlOrNull() != null && Patterns.WEB_URL.matcher(this).matches()
fun String.isBaseUrlInvalid(): Boolean { fun String.isBaseUrlInvalid(): Boolean {
val baseUrl = this.toHttpUrlOrNull() val baseUrl = this.toHttpUrlOrNull()
@ -66,7 +64,10 @@ fun Context.openInBrowserAsNewTask(i: SelfossModel.Item) {
} }
class LinkOnTouchListener : View.OnTouchListener { class LinkOnTouchListener : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean { override fun onTouch(
v: View?,
event: MotionEvent?,
): Boolean {
var ret = false var ret = false
val widget: TextView = v as TextView val widget: TextView = v as TextView
val text: CharSequence = widget.text val text: CharSequence = widget.text

View File

@ -8,5 +8,4 @@ fun TextBadgeItem.removeBadge(): TextBadgeItem {
return this return this
} }
fun TextBadgeItem.maybeShow(): TextBadgeItem = fun TextBadgeItem.maybeShow(): TextBadgeItem = if (this.isHidden) this.show() else this
if (this.isHidden) this.show() else this

View File

@ -10,24 +10,32 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
fun Context.bitmapCenterCrop(url: String, iv: ImageView) = fun Context.bitmapCenterCrop(
Glide.with(this) url: String,
.asBitmap() iv: ImageView,
.load(url) ) = Glide.with(this)
.apply(RequestOptions.centerCropTransform()) .asBitmap()
.into(iv) .load(url)
.apply(RequestOptions.centerCropTransform())
.into(iv)
fun Context.circularDrawable(url: String, view: CircleImageView) { fun Context.circularDrawable(
view.textView.text ="" url: String,
view: CircleImageView,
) {
view.textView.text = ""
Glide.with(this) Glide.with(this)
.load(url) .load(url)
.into(view.imageView) .into(view.imageView)
} }
fun getBitmapInputStream(bitmap:Bitmap,compressFormat: Bitmap.CompressFormat): InputStream { fun getBitmapInputStream(
bitmap: Bitmap,
compressFormat: Bitmap.CompressFormat,
): InputStream {
val byteArrayOutputStream = ByteArrayOutputStream() val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(compressFormat, 80, byteArrayOutputStream) bitmap.compress(compressFormat, 80, byteArrayOutputStream)
val bitmapData: ByteArray = byteArrayOutputStream.toByteArray() val bitmapData: ByteArray = byteArrayOutputStream.toByteArray()
return ByteArrayInputStream(bitmapData) return ByteArrayInputStream(bitmapData)
} }

View File

@ -26,4 +26,4 @@ fun isNetworkAccessible(context: Context): Boolean {
val network = connectivityManager.activeNetworkInfo ?: return false val network = connectivityManager.activeNetworkInfo ?: return false
return network.isConnectedOrConnecting return network.isConnectedOrConnecting
} }
} }

View File

@ -19,12 +19,13 @@ class AppViewModel(private val repository: Repository) : ViewModel() {
if (isConnected && !wasConnected && repository.connectionMonitored) { if (isConnected && !wasConnected && repository.connectionMonitored) {
_networkAvailableProvider.emit(true) _networkAvailableProvider.emit(true)
wasConnected = true wasConnected = true
} else if (!isConnected && wasConnected && repository.connectionMonitored){ } else if (!isConnected && wasConnected && repository.connectionMonitored)
_networkAvailableProvider.emit(false) {
wasConnected = false _networkAvailableProvider.emit(false)
} wasConnected = false
}
} }
} }
} }
} }
} }

View File

@ -43,7 +43,7 @@
android:id="@+id/sourceImage" android:id="@+id/sourceImage"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="8dp" android:layout_marginStart="16dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -54,19 +54,24 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="8dp" android:layout_margin="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/sourceImage" app:layout_constraintStart_toEndOf="@+id/sourceImage"
app:layout_constraintTop_toTopOf="@+id/sourceImage" app:layout_constraintTop_toTopOf="parent"
tools:text="Titre" /> tools:text="Titre" />
<TextView <TextView
android:id="@+id/sourceTitleAndDate" android:id="@+id/sourceTitleAndDate"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="14sp" android:textSize="14sp"
@ -79,7 +84,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginEnd="8dp" android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sourceTitleAndDate"> app:layout_constraintTop_toBottomOf="@+id/sourceTitleAndDate">

View File

@ -38,15 +38,16 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:gravity="start" android:gravity="start"
android:maxLines="1"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
android:textSize="14sp" android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/itemImage" app:layout_constraintStart_toEndOf="@+id/itemImage"
app:layout_constraintTop_toBottomOf="@+id/itemImage" app:layout_constraintTop_toBottomOf="@+id/title"
tools:text="Google Actualité Il y a 5h" /> tools:text="Google Actualité Il y a 5h" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -36,6 +36,12 @@
android:orderInCategory="101" android:orderInCategory="101"
android:title="@string/menu_home_refresh" /> android:title="@string/menu_home_refresh" />
<item
android:id="@+id/issue_tracker"
app:showAsAction="never"
android:orderInCategory="103"
android:title="@string/issue_tracker_link" />
<item android:id="@+id/action_disconnect" <item android:id="@+id/action_disconnect"
android:title="@string/action_disconnect" android:title="@string/action_disconnect"
android:orderInCategory="104" android:orderInCategory="104"

View File

@ -3,6 +3,13 @@
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/issue_tracker"
app:showAsAction="never"
android:orderInCategory="101"
android:title="@string/issue_tracker_link" />
<item android:id="@+id/about" <item android:id="@+id/about"
android:title="@string/action_about" android:title="@string/action_about"
android:orderInCategory="102" android:orderInCategory="102"

View File

@ -0,0 +1,77 @@
package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import android.widget.Button
import android.widget.EditText
import androidx.core.view.isVisible
import bou.amine.apps.readerforselfossv2.android.LoginActivity
import bou.amine.apps.readerforselfossv2.android.R
import com.google.android.material.switchmaterial.SwitchMaterial
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@RunWith(RobotElectriqueRunnerclass::class)
class LoginActivityTest {
@Test
fun login_shouldDisplay() {
Robolectric.buildActivity(LoginActivity::class.java).use { controller ->
controller.setup() // Moves the Activity to the RESUMED state
val activity = controller.get()
assert(activity.findViewById<EditText>(R.id.urlView).isVisible)
assert(activity.findViewById<SwitchMaterial>(R.id.selfSigned).isVisible)
assert(activity.findViewById<SwitchMaterial>(R.id.selfSigned).isChecked.not())
assert(activity.findViewById<SwitchMaterial>(R.id.withLogin).isVisible)
assert(activity.findViewById<SwitchMaterial>(R.id.withLogin).isChecked.not())
}
}
@Test
fun urlError() {
Robolectric.buildActivity(LoginActivity::class.java).use { controller ->
controller.setup() // Moves the Activity to the RESUMED state
val activity = controller.get()
val urlView = activity.findViewById<EditText>(R.id.urlView)
urlView.setText("172.17.0.1:8888")
activity.findViewById<Button>(R.id.signInButton).performClick()
urlView.performClick()
assertEquals(activity.getString(R.string.login_url_problem), urlView.error)
}
}
@Test
fun multiError() {
Robolectric.buildActivity(LoginActivity::class.java).use { controller ->
controller.setup() // Moves the Activity to the RESUMED state
val activity = controller.get()
val signInButton = activity.findViewById<Button>(R.id.signInButton)
repeat(3) { signInButton.performClick() }
// Vérifie que l'avertissement est affiché
assertEquals(activity.getString(R.string.text_wrong_url), dialogMessage())
}
}
/* @Test
fun connect() {
Robolectric.buildActivity(LoginActivity::class.java).use { controller ->
controller.setup() // Moves the Activity to the RESUMED state
val activity = controller.get()
val signInButton = activity.findViewById<Button>(R.id.signInButton)
val urlView = activity.findViewById<EditText>(R.id.urlView)
urlView.setText("http://10.0.2.2:8888")
signInButton.performClick()
val expectedIntent = Intent(activity, HomeActivity::class.java)
val actual = shadowOf(activity).nextStartedActivity
assertEquals(expectedIntent.component, actual.component)
}
}*/
}

View File

@ -0,0 +1,12 @@
package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
class RobotElectriqueRunnerclass(testClass: Class<*>?) :
RobolectricTestRunner(testClass) {
override fun buildGlobalConfig(): Config {
return Config.Builder().setSdk(25, 30, 33).build()
}
}

View File

@ -0,0 +1,23 @@
package bou.amine.apps.readerforselfossv2.android.tests.robolectric
import android.view.Menu
import android.widget.TextView
import androidx.annotation.IdRes
import org.junit.Assert.assertTrue
import org.robolectric.shadows.ShadowDialog
fun dialogMessage(): String {
val latestDialog = ShadowDialog.getLatestDialog()
return latestDialog.findViewById<TextView>(android.R.id.message)?.text.toString()
}
fun Menu.assertClickable(@IdRes id: Int) {
this.assertVisible(id)
val item = this.findItem(id)
assertTrue(item.isEnabled)
}
fun Menu.assertVisible(@IdRes id: Int) {
val item = this.findItem(id)
assertTrue(item.isVisible)
}

View File

@ -1,4 +1,4 @@
package bou.amine.apps.readerforselfossv2.repository package bou.amine.apps.readerforselfossv2.tests.repository
import bou.amine.apps.readerforselfossv2.dao.ReaderForSelfossDB import bou.amine.apps.readerforselfossv2.dao.ReaderForSelfossDB
import bou.amine.apps.readerforselfossv2.dao.SOURCE import bou.amine.apps.readerforselfossv2.dao.SOURCE
@ -6,12 +6,22 @@ import bou.amine.apps.readerforselfossv2.dao.TAG
import bou.amine.apps.readerforselfossv2.model.SelfossModel 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.model.SuccessResponse import bou.amine.apps.readerforselfossv2.model.SuccessResponse
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.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.* import io.mockk.clearAllMocks
import junit.framework.TestCase.* import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotSame
import junit.framework.TestCase.assertSame
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
@ -42,11 +52,11 @@ class RepositoryTest {
private val NUMBER_STARRED = 20 private val NUMBER_STARRED = 20
private lateinit var repository: Repository private lateinit var repository: Repository
private fun initializeRepository( private fun initializeRepository(
isConnectionAvailable: MutableStateFlow<Boolean> = MutableStateFlow( isConnectionAvailable: MutableStateFlow<Boolean> =
true MutableStateFlow(
) true,
),
) { ) {
repository = Repository(api, appSettingsService, isConnectionAvailable, db) repository = Repository(api, appSettingsService, isConnectionAvailable, db)
@ -64,14 +74,20 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns false every { appSettingsService.isItemCachingEnabled() } returns false
every { appSettingsService.isUpdateSourcesEnabled() } returns false every { appSettingsService.isUpdateSourcesEnabled() } returns false
coEvery { api.apiInformation() } returns StatusAndData( coEvery { api.apiInformation() } returns
success = true, StatusAndData(
data = SelfossModel.ApiInformation("2.19-ba1e8e3", "4.0.0", SelfossModel.ApiConfiguration(false, true)) success = true,
) data = SelfossModel.ApiInformation(
coEvery { api.stats() } returns StatusAndData( "2.19-ba1e8e3",
success = true, "4.0.0",
data = SelfossModel.Stats(NUMBER_ARTICLES, NUMBER_UNREAD, NUMBER_STARRED) SelfossModel.ApiConfiguration(false, true)
) ),
)
coEvery { api.stats() } returns
StatusAndData(
success = true,
data = SelfossModel.Stats(NUMBER_ARTICLES, NUMBER_UNREAD, NUMBER_STARRED),
)
every { db.itemsQueries.deleteItemsWhereSource(any()) } returns Unit every { db.itemsQueries.deleteItemsWhereSource(any()) } returns Unit
every { db.itemsQueries.items().executeAsList() } returns generateTestDBItems() every { db.itemsQueries.items().executeAsList() } returns generateTestDBItems()
@ -116,10 +132,15 @@ class RepositoryTest {
@Test @Test
fun get_public_access() { fun get_public_access() {
every { appSettingsService.updatePublicAccess(any()) } returns Unit every { appSettingsService.updatePublicAccess(any()) } returns Unit
coEvery { api.apiInformation() } returns StatusAndData( coEvery { api.apiInformation() } returns
success = true, StatusAndData(
data = SelfossModel.ApiInformation("2.19-ba1e8e3", "4.0.0", SelfossModel.ApiConfiguration(true, true)) success = true,
) data = SelfossModel.ApiInformation(
"2.19-ba1e8e3",
"4.0.0",
SelfossModel.ApiConfiguration(true, true)
),
)
every { appSettingsService.getUserName() } returns "" every { appSettingsService.getUserName() } returns ""
initializeRepository() initializeRepository()
@ -131,10 +152,15 @@ class RepositoryTest {
@Test @Test
fun get_public_access_username_not_empty() { fun get_public_access_username_not_empty() {
every { appSettingsService.updatePublicAccess(any()) } returns Unit every { appSettingsService.updatePublicAccess(any()) } returns Unit
coEvery { api.apiInformation() } returns StatusAndData( coEvery { api.apiInformation() } returns
success = true, StatusAndData(
data = SelfossModel.ApiInformation("2.19-ba1e8e3", "4.0.0", SelfossModel.ApiConfiguration(true, true)) success = true,
) data = SelfossModel.ApiInformation(
"2.19-ba1e8e3",
"4.0.0",
SelfossModel.ApiConfiguration(true, true)
),
)
every { appSettingsService.getUserName() } returns "username" every { appSettingsService.getUserName() } returns "username"
initializeRepository() initializeRepository()
@ -146,10 +172,15 @@ class RepositoryTest {
@Test @Test
fun get_public_access_no_auth() { fun get_public_access_no_auth() {
every { appSettingsService.updatePublicAccess(any()) } returns Unit every { appSettingsService.updatePublicAccess(any()) } returns Unit
coEvery { api.apiInformation() } returns StatusAndData( coEvery { api.apiInformation() } returns
success = true, StatusAndData(
data = SelfossModel.ApiInformation("2.19-ba1e8e3", "4.0.0", SelfossModel.ApiConfiguration(true, false)) success = true,
) data = SelfossModel.ApiInformation(
"2.19-ba1e8e3",
"4.0.0",
SelfossModel.ApiConfiguration(true, false)
),
)
every { appSettingsService.getUserName() } returns "" every { appSettingsService.getUserName() } returns ""
initializeRepository() initializeRepository()
@ -161,10 +192,15 @@ class RepositoryTest {
@Test @Test
fun get_public_access_disabled() { fun get_public_access_disabled() {
every { appSettingsService.updatePublicAccess(any()) } returns Unit every { appSettingsService.updatePublicAccess(any()) } returns Unit
coEvery { api.apiInformation() } returns StatusAndData( coEvery { api.apiInformation() } returns
success = true, StatusAndData(
data = SelfossModel.ApiInformation("2.19-ba1e8e3", "4.0.0", SelfossModel.ApiConfiguration(false, true)) success = true,
) data = SelfossModel.ApiInformation(
"2.19-ba1e8e3",
"4.0.0",
SelfossModel.ApiConfiguration(false, true)
),
)
every { appSettingsService.getUserName() } returns "" every { appSettingsService.getUserName() } returns ""
initializeRepository() initializeRepository()
@ -182,7 +218,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( StatusAndData(
success = true, success = true,
data = generateTestApiItem(itemParameters) data = generateTestApiItem(itemParameters),
) )
initializeRepository() initializeRepository()
@ -264,7 +300,7 @@ class RepositoryTest {
itemParameter3.tags = "Other, Tag" itemParameter3.tags = "Other, Tag"
itemParameter3.id = "3" itemParameter3.id = "3"
coEvery { db.itemsQueries.items().executeAsList() } returns generateTestDBItems( coEvery { db.itemsQueries.items().executeAsList() } returns generateTestDBItems(
itemParameter1 itemParameter1,
) + ) +
generateTestDBItems(itemParameter2) + generateTestDBItems(itemParameter2) +
generateTestDBItems(itemParameter3) generateTestDBItems(itemParameter3)
@ -292,7 +328,7 @@ class RepositoryTest {
itemParameter3.sourcetitle = "Other" itemParameter3.sourcetitle = "Other"
itemParameter3.id = "3" itemParameter3.id = "3"
coEvery { db.itemsQueries.items().executeAsList() } returns generateTestDBItems( coEvery { db.itemsQueries.items().executeAsList() } returns generateTestDBItems(
itemParameter1 itemParameter1,
) + ) +
generateTestDBItems(itemParameter2) + generateTestDBItems(itemParameter2) +
generateTestDBItems(itemParameter3) generateTestDBItems(itemParameter3)
@ -300,16 +336,18 @@ class RepositoryTest {
every { appSettingsService.isItemCachingEnabled() } returns true every { appSettingsService.isItemCachingEnabled() } returns true
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
repository.setSourceFilter(SelfossModel.SourceDetail( repository.setSourceFilter(
1, SelfossModel.SourceDetail(
"Test", 1,
null, "Test",
listOf("tags"), null,
SPOUT, listOf("tags"),
"", SPOUT,
IMAGE_URL, "",
SelfossModel.SourceParams("url") IMAGE_URL,
)) SelfossModel.SourceParams("url"),
),
)
runBlocking { runBlocking {
repository.getNewerItems() repository.getNewerItems()
} }
@ -592,14 +630,16 @@ class RepositoryTest {
} }
private fun prepareTags(): Pair<List<SelfossModel.Tag>, List<TAG>> { private fun prepareTags(): Pair<List<SelfossModel.Tag>, List<TAG>> {
val tags = listOf( val tags =
SelfossModel.Tag("test", "red", 6), listOf(
SelfossModel.Tag("second", "yellow", 0) SelfossModel.Tag("test", "red", 6),
) SelfossModel.Tag("second", "yellow", 0),
val tagsDB = listOf( )
TAG("test_DB", "red", 6), val tagsDB =
TAG("second_DB", "yellow", 0) listOf(
) TAG("test_DB", "red", 6),
TAG("second_DB", "yellow", 0),
)
coEvery { api.tags() } returns StatusAndData(success = true, data = tags) coEvery { api.tags() } returns StatusAndData(success = true, data = tags)
coEvery { db.tagsQueries.tags().executeAsList() } returns tagsDB coEvery { db.tagsQueries.tags().executeAsList() } returns tagsDB
@ -621,48 +661,50 @@ class RepositoryTest {
} }
private fun prepareSources(): Pair<ArrayList<SelfossModel.SourceDetail>, List<SOURCE>> { private fun prepareSources(): Pair<ArrayList<SelfossModel.SourceDetail>, List<SOURCE>> {
val sources = arrayListOf( val sources =
SelfossModel.SourceDetail( arrayListOf(
1, SelfossModel.SourceDetail(
"First source", 1,
null, "First source",
listOf("Test", "second"), null,
SPOUT, listOf("Test", "second"),
"", SPOUT,
IMAGE_URL_2, "",
SelfossModel.SourceParams("url") IMAGE_URL_2,
), SelfossModel.SourceParams("url"),
SelfossModel.SourceDetail( ),
2, SelfossModel.SourceDetail(
"Second source", 2,
null, "Second source",
listOf("second"), null,
SPOUT, listOf("second"),
"", SPOUT,
IMAGE_URL, "",
SelfossModel.SourceParams("url") IMAGE_URL,
SelfossModel.SourceParams("url"),
),
) )
) val sourcesDB =
val sourcesDB = listOf( listOf(
SOURCE( SOURCE(
"1", "1",
"First DB source", "First DB source",
"Test,second", "Test,second",
SPOUT, SPOUT,
"", "",
IMAGE_URL_2, IMAGE_URL_2,
"url" "url",
), ),
SOURCE( SOURCE(
"2", "2",
"Second source", "Second source",
"second", "second",
SPOUT, SPOUT,
"", "",
IMAGE_URL, IMAGE_URL,
"url" "url",
),
) )
)
coEvery { api.sourcesDetailed() } returns StatusAndData(success = true, data = sources) coEvery { api.sourcesDetailed() } returns StatusAndData(success = true, data = sources)
every { db.sourcesQueries.sources().executeAsList() } returns sourcesDB every { db.sourcesQueries.sources().executeAsList() } returns sourcesDB
@ -796,12 +838,13 @@ class RepositoryTest {
initializeRepository() initializeRepository()
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.createSource( response =
"test", repository.createSource(
FEED_URL, "test",
SPOUT, FEED_URL,
TAGS, SPOUT,
) TAGS,
)
} }
coVerify(exactly = 1) { coVerify(exactly = 1) {
@ -823,12 +866,13 @@ class RepositoryTest {
initializeRepository() initializeRepository()
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.createSource( response =
"test", repository.createSource(
FEED_URL, "test",
SPOUT, FEED_URL,
TAGS SPOUT,
) TAGS,
)
} }
coVerify(exactly = 1) { coVerify(exactly = 1) {
@ -836,7 +880,7 @@ class RepositoryTest {
any(), any(),
any(), any(),
any(), any(),
any() any(),
) )
} }
assertSame(false, response) assertSame(false, response)
@ -850,12 +894,13 @@ class RepositoryTest {
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var response: Boolean var response: Boolean
runBlocking { runBlocking {
response = repository.createSource( response =
"test", repository.createSource(
FEED_URL, "test",
SPOUT, FEED_URL,
TAGS SPOUT,
) TAGS,
)
} }
coVerify(exactly = 0) { coVerify(exactly = 0) {
@ -916,10 +961,11 @@ class RepositoryTest {
@Test @Test
fun update_remote() { fun update_remote() {
coEvery { api.update() } returns StatusAndData( coEvery { api.update() } returns
success = true, StatusAndData(
data = "finished" success = true,
) data = "finished",
)
initializeRepository() initializeRepository()
var response: Boolean var response: Boolean
@ -933,10 +979,11 @@ class RepositoryTest {
@Test @Test
fun update_remote_but_response_fails() { fun update_remote_but_response_fails() {
coEvery { api.update() } returns StatusAndData( coEvery { api.update() } returns
success = false, StatusAndData(
data = "unallowed access" success = false,
) data = "unallowed access",
)
initializeRepository() initializeRepository()
var response: Boolean var response: Boolean
@ -950,10 +997,11 @@ class RepositoryTest {
@Test @Test
fun update_remote_with_unallowed_access() { fun update_remote_with_unallowed_access() {
coEvery { api.update() } returns StatusAndData( coEvery { api.update() } returns
success = true, StatusAndData(
data = "unallowed access" success = true,
) data = "unallowed access",
)
initializeRepository() initializeRepository()
var response: Boolean var response: Boolean
@ -967,10 +1015,11 @@ class RepositoryTest {
@Test @Test
fun update_remote_without_connection() { fun update_remote_without_connection() {
coEvery { api.update() } returns StatusAndData( coEvery { api.update() } returns
success = true, StatusAndData(
data = "undocumented..." success = true,
) data = "undocumented...",
)
initializeRepository(MutableStateFlow(false)) initializeRepository(MutableStateFlow(false))
var response: Boolean var response: Boolean
@ -1037,7 +1086,7 @@ class RepositoryTest {
appSettingsService.refreshLoginInformation( appSettingsService.refreshLoginInformation(
BASE_URL, BASE_URL,
"login", "login",
"password" "password",
) )
} }
} }
@ -1057,13 +1106,14 @@ class RepositoryTest {
any(), any(),
any(), any(),
any(), any(),
any() any(),
) )
} returnsMany listOf( } returnsMany
StatusAndData(success = true, data = generateTestApiItem(itemParameter1)), listOf(
StatusAndData(success = true, data = generateTestApiItem(itemParameter2)), StatusAndData(success = true, data = generateTestApiItem(itemParameter1)),
StatusAndData(success = true, data = generateTestApiItem(itemParameter1)), StatusAndData(success = true, data = generateTestApiItem(itemParameter2)),
) StatusAndData(success = true, data = generateTestApiItem(itemParameter1)),
)
initializeRepository() initializeRepository()
prepareSearch() prepareSearch()
@ -1113,8 +1163,8 @@ class RepositoryTest {
SPOUT, SPOUT,
"", "",
IMAGE_URL_2, IMAGE_URL_2,
SelfossModel.SourceParams("url") SelfossModel.SourceParams("url"),
) ),
) )
repository.searchFilter = "search" repository.searchFilter = "search"
} }

View File

@ -1,9 +1,8 @@
package bou.amine.apps.readerforselfossv2.repository package bou.amine.apps.readerforselfossv2.tests.repository
import bou.amine.apps.readerforselfossv2.dao.ITEM import bou.amine.apps.readerforselfossv2.dao.ITEM
import bou.amine.apps.readerforselfossv2.model.SelfossModel import bou.amine.apps.readerforselfossv2.model.SelfossModel
fun generateTestDBItems(item: FakeItemParameters = FakeItemParameters()): List<ITEM> { fun generateTestDBItems(item: FakeItemParameters = FakeItemParameters()): List<ITEM> {
return listOf( return listOf(
ITEM( ITEM(
@ -18,8 +17,8 @@ fun generateTestDBItems(item: FakeItemParameters = FakeItemParameters()): List<I
link = item.link, link = item.link,
sourcetitle = item.sourcetitle, sourcetitle = item.sourcetitle,
tags = item.tags, tags = item.tags,
author = item.author author = item.author,
) ),
) )
} }
@ -37,8 +36,8 @@ fun generateTestApiItem(item: FakeItemParameters = FakeItemParameters()): List<S
link = item.link, link = item.link,
sourcetitle = item.sourcetitle, sourcetitle = item.sourcetitle,
tags = item.tags.split(','), tags = item.tags.split(','),
author = item.author author = item.author,
) ),
) )
} }

View File

@ -7,17 +7,16 @@ buildscript {
plugins { plugins {
//trick: for the same plugin versions in all sub-modules //trick: for the same plugin versions in all sub-modules
id("com.android.application").version("8.1.2").apply(false) id("com.android.application").version("8.7.3").apply(false)
id("com.android.library").version("8.1.2").apply(false) id("com.android.library").version("8.7.3").apply(false)
id("org.jetbrains.kotlin.android").version("1.9.10").apply(false) id("org.jetbrains.kotlin.android").version("2.1.0").apply(false)
kotlin("multiplatform").version("1.9.10").apply(false) kotlin("multiplatform").version("2.1.0").apply(false)
id("com.mikepenz.aboutlibraries.plugin").version("10.5.1").apply(false) id("com.mikepenz.aboutlibraries.plugin").version("10.5.1").apply(false)
id("org.jetbrains.kotlinx.kover").version("0.6.1").apply(true) id("org.jetbrains.kotlinx.kover") version "0.9.0" apply true
} }
allprojects { allprojects {
repositories { repositories {
// maven { url = uri("https://nexus.amine-louveau.fr/repository/maven-public/")}
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://www.jitpack.io") } maven { url = uri("https://www.jitpack.io") }
@ -26,9 +25,10 @@ allprojects {
tasks.register("clean", Delete::class) { tasks.register("clean", Delete::class) {
delete(rootProject.buildDir) delete(layout.buildDirectory)
} }
koverMerged { dependencies {
enable() kover(project(":shared"))
kover(project(":androidApp"))
} }

View File

@ -68,9 +68,9 @@ redirect_from: "/ReaderforSelfoss-multiplatform/"
<div id="links"> <div id="links">
<a class="github-button" href="https://gitea.amine-louveau.fr/Louvorg/readerforselfoss-multiplatform" data-size="large" aria-label="Star aminecmi/readerforselfoss-multiplatform on GitHub">Star</a> <a class="github-button" href="https://gitea.amine-bouabdallaoui.fr/Louvorg/readerforselfoss-multiplatform" data-size="large" aria-label="Star aminecmi/readerforselfoss-multiplatform on GitHub">Star</a>
</div> </div>
<meta itemprop="url" content="https://gitea.amine-louveau.fr/Louvorg/readerforselfoss-multiplatform"> <meta itemprop="url" content="https://gitea.amine-bouabdallaoui.fr/Louvorg/readerforselfoss-multiplatform">
<meta itemprop="applicationCategory" content="News & Magazines"> <meta itemprop="applicationCategory" content="News & Magazines">
</div> </div>
</body> </body>

View File

@ -0,0 +1,6 @@
**v124113311**
- chore: update versions. (#165)
- chore: fastlane changelog.
- chore: fastlane fixes.
- Changelog for v124113301

View File

@ -0,0 +1,4 @@
**v124123421**
- fix: Trying to fix the serialization issue.
- Changelog for v124113311

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

View File

@ -1 +1 @@
A new RSS reader for <a href="http://selfoss.aditu.de/">selfoss</a>. A new RSS reader for selfoss (http://selfoss.aditu.de/)

View File

@ -26,3 +26,4 @@ org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
ignoreGitVersion=false ignoreGitVersion=false
kotlin.native.cacheKind.iosX64=none kotlin.native.cacheKind.iosX64=none
org.gradle.configureondemand=true

View File

@ -1,6 +1,6 @@
#Thu Jul 13 11:41:19 CEST 2023 #Mon Nov 25 22:48:24 CET 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -173,7 +173,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; shellScript = "export JAVA_HOME=/Users/amine/.sdkman/candidates/java/17.0.8.1-jbr\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode --stacktrace\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */

View File

@ -1,6 +1,5 @@
pluginManagement { pluginManagement {
repositories { repositories {
// maven { url = uri("https://nexus.amine-louveau.fr/repository/maven-public/")}
google() google()
gradlePluginPortal() gradlePluginPortal()
mavenCentral() mavenCentral()
@ -9,7 +8,6 @@ pluginManagement {
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
// maven { url = uri("https://nexus.amine-louveau.fr/repository/maven-public/")}
google() google()
mavenCentral() mavenCentral()
} }

View File

@ -52,6 +52,9 @@ kotlin {
// Sql // Sql
implementation(SqlDelight.runtime) implementation(SqlDelight.runtime)
// Sql
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
} }
} }
val commonTest by getting { val commonTest by getting {
@ -80,10 +83,6 @@ kotlin {
val iosArm64Main by getting val iosArm64Main by getting
// val iosSimulatorArm64Main by getting // val iosSimulatorArm64Main by getting
val iosMain by creating { val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
// iosSimulatorArm64Main.dependsOn(this)
dependencies { dependencies {
implementation(SqlDelight.native) implementation(SqlDelight.native)
@ -94,10 +93,6 @@ kotlin {
val iosArm64Test by getting val iosArm64Test by getting
// val iosSimulatorArm64Test by getting // val iosSimulatorArm64Test by getting
val iosTest by creating { val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
iosArm64Test.dependsOn(this)
// iosSimulatorArm64Test.dependsOn(this)
} }
} }
} }

View File

@ -7,4 +7,4 @@ actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver { actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(ReaderForSelfossDB.Schema, context, "ReaderForSelfossV2-android.db") return AndroidSqliteDriver(ReaderForSelfossDB.Schema, context, "ReaderForSelfossV2-android.db")
} }
} }

View File

@ -5,13 +5,19 @@ import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
class NaiveTrustManager : X509TrustManager { class NaiveTrustManager : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {} override fun checkClientTrusted(
chain: Array<out X509Certificate>?,
authType: String?,
) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {} override fun checkServerTrusted(
chain: Array<out X509Certificate>?,
authType: String?,
) {}
override fun getAcceptedIssuers(): Array<out X509Certificate> = arrayOf() override fun getAcceptedIssuers(): Array<out X509Certificate> = arrayOf()
} }
actual fun setupInsecureHTTPEngine(config: CIOEngineConfig) { actual fun setupInsecureHTTPEngine(config: CIOEngineConfig) {
config.https.trustManager = NaiveTrustManager() config.https.trustManager = NaiveTrustManager()
} }

View File

@ -1,42 +1,21 @@
package bou.amine.apps.readerforselfossv2.utils package bou.amine.apps.readerforselfossv2.utils
import android.text.format.DateUtils import android.text.format.DateUtils
import io.github.aakira.napier.Napier
import kotlinx.datetime.* import kotlinx.datetime.*
actual class DateUtils { actual class DateUtils {
actual companion object { actual companion object {
// Possible formats are
// yyyy-mm-dd hh:mm:ss format
private val oldVersionFormat = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(.()\\d*)?".toRegex()
// yyyy-MM-dd'T'HH:mm:ss[.SSS]XXX (RFC3339)
private val newVersionFormat = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\+\\d{2}(:\\d{2})?".toRegex()
// We may need to consider moving the formatting to platform specific code, even if the tests are doubled
// For now, we handle this in a hacky way, because kotlin only accepts iso formats
actual fun parseDate(dateString: String): Long {
var isoDateString: String = if (dateString.matches(oldVersionFormat)) {
dateString.replace(" ", "T")
} else if (dateString.matches(newVersionFormat)) {
dateString.split("+")[0]
} else {
throw Exception("Unrecognized format for $dateString")
}
return LocalDateTime.parse(isoDateString).toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()
}
actual fun parseRelativeDate(dateString: String): String { actual fun parseRelativeDate(dateString: String): String {
val date = dateString.toParsedDate()
val date = parseDate(dateString) return " " +
DateUtils.getRelativeTimeSpanString(
return " " + DateUtils.getRelativeTimeSpanString( date,
date, Clock.System.now().toEpochMilliseconds(),
Clock.System.now().toEpochMilliseconds(), DateUtils.MINUTE_IN_MILLIS,
DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
DateUtils.FORMAT_ABBREV_RELATIVE )
)
} }
} }
} }

View File

@ -21,13 +21,13 @@ actual fun SelfossModel.Item.getThumbnail(baseUrl: String): String {
actual fun SelfossModel.Item.getImages(): ArrayList<String> { actual fun SelfossModel.Item.getImages(): ArrayList<String> {
val allImages = ArrayList<String>() val allImages = ArrayList<String>()
for ( image in Jsoup.parse(content).getElementsByTag("img")) { for (image in Jsoup.parse(content).getElementsByTag("img")) {
val url = image.attr("src") val url = image.attr("src")
if (url.lowercase(Locale.US).contains(".jpg") || if (url.lowercase(Locale.US).contains(".jpg") ||
url.lowercase(Locale.US).contains(".jpeg") || url.lowercase(Locale.US).contains(".jpeg") ||
url.lowercase(Locale.US).contains(".png") || url.lowercase(Locale.US).contains(".png") ||
url.lowercase(Locale.US).contains(".webp")) url.lowercase(Locale.US).contains(".webp")
{ ) {
allImages.add(url) allImages.add(url)
} }
} }
@ -38,7 +38,11 @@ actual fun SelfossModel.Source.getIcon(baseUrl: String): String {
return constructUrl(baseUrl, "favicons", icon) return constructUrl(baseUrl, "favicons", icon)
} }
actual fun constructUrl(baseUrl: String, path: String, file: String?): String { actual fun constructUrl(
baseUrl: String,
path: String,
file: String?,
): String {
return if (file == null || file == "null" || file.isEmpty()) { return if (file == null || file == "null" || file.isEmpty()) {
"" ""
} else { } else {
@ -47,4 +51,4 @@ actual fun constructUrl(baseUrl: String, path: String, file: String?): String {
baseUriBuilder.toString() baseUriBuilder.toString()
} }
} }

View File

@ -2,7 +2,6 @@ package bou.amine.apps.readerforselfossv2.DI
import bou.amine.apps.readerforselfossv2.rest.MercuryApi import bou.amine.apps.readerforselfossv2.rest.MercuryApi
import bou.amine.apps.readerforselfossv2.rest.SelfossApi import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.service.AppSettingsService
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.bind import org.kodein.di.bind
import org.kodein.di.instance import org.kodein.di.instance
@ -11,4 +10,4 @@ import org.kodein.di.singleton
val networkModule by DI.Module { val networkModule by DI.Module {
bind<SelfossApi>() with singleton { SelfossApi(instance()) } bind<SelfossApi>() with singleton { SelfossApi(instance()) }
bind<MercuryApi>() with singleton { MercuryApi() } bind<MercuryApi>() with singleton { MercuryApi() }
} }

View File

@ -4,4 +4,4 @@ import com.squareup.sqldelight.db.SqlDriver
expect class DriverFactory { expect class DriverFactory {
fun createDriver(): SqlDriver fun createDriver(): SqlDriver
} }

View File

@ -3,7 +3,6 @@ package bou.amine.apps.readerforselfossv2.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class MercuryModel { class MercuryModel {
@Serializable @Serializable
class ParsedContent( class ParsedContent(
val title: String? = null, val title: String? = null,
@ -12,6 +11,6 @@ class MercuryModel {
val url: String? = null, val url: String? = null,
val error: Boolean? = null, val error: Boolean? = null,
val message: String? = null, val message: String? = null,
val failed: Boolean? = null val failed: Boolean? = null,
) )
} }

View File

@ -1,3 +1,3 @@
package bou.amine.apps.readerforselfossv2.model package bou.amine.apps.readerforselfossv2.model
class NetworkUnavailableException : Exception() class NetworkUnavailableException : Exception()

View File

@ -18,4 +18,4 @@ class StatusAndData<T>(val success: Boolean, val data: T? = null) {
return StatusAndData(false) return StatusAndData(false)
} }
} }
} }

View File

@ -13,32 +13,31 @@ import kotlinx.serialization.encoding.encodeCollection
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
class SelfossModel { class SelfossModel {
@Serializable @Serializable
data class Tag( data class Tag(
val tag: String, val tag: String,
val color: String, val color: String,
val unread: Int val unread: Int,
) )
@Serializable @Serializable
class Stats( class Stats(
val total: Int, val total: Int,
val unread: Int? = null, val unread: Int? = null,
val starred: Int? = null val starred: Int? = null,
) )
@Serializable @Serializable
data class Spout( data class Spout(
val name: String, val name: String,
val description: String val description: String,
) )
@Serializable @Serializable
data class ApiInformation( data class ApiInformation(
val version: String? = null, val version: String? = null,
val apiversion: String? = null, val apiversion: String? = null,
val configuration: ApiConfiguration? = null val configuration: ApiConfiguration? = null,
) { ) {
fun getApiMajorVersion(): Int { fun getApiMajorVersion(): Int {
var versionNumber = 0 var versionNumber = 0
@ -56,7 +55,7 @@ class SelfossModel {
@Serializable(with = BooleanSerializer::class) @Serializable(with = BooleanSerializer::class)
val publicMode: Boolean? = null, val publicMode: Boolean? = null,
@Serializable(with = BooleanSerializer::class) @Serializable(with = BooleanSerializer::class)
val authEnabled: Boolean? = null val authEnabled: Boolean? = null,
) { ) {
fun isAuthEnabled() = authEnabled ?: true fun isAuthEnabled() = authEnabled ?: true
@ -77,8 +76,8 @@ class SelfossModel {
override var title: String, override var title: String,
override var unread: Int? = null, override var unread: Int? = null,
override var error: String? = null, override var error: String? = null,
override var icon: String? = null override var icon: String? = null,
) : Source ) : Source
@Serializable @Serializable
data class SourceDetail( data class SourceDetail(
@ -90,13 +89,14 @@ class SelfossModel {
var spout: String? = null, var spout: String? = null,
override var error: String? = null, override var error: String? = null,
override var icon: String? = null, override var icon: String? = null,
var params: SourceParams? = null var params: SourceParams? = null,
) : Source ) : Source
@Serializable @Serializable
data class SourceParams( data class SourceParams(
val url: String? = null val url: String? = null,
) )
@Serializable @Serializable
data class Item( data class Item(
val id: Int, val id: Int,
@ -113,15 +113,16 @@ class SelfossModel {
val sourcetitle: String, val sourcetitle: String,
@Serializable(with = TagsListSerializer::class) @Serializable(with = TagsListSerializer::class)
val tags: List<String>, val tags: List<String>,
val author: String? = null val author: String? = null,
) { ) {
fun getLinkDecoded(): String { fun getLinkDecoded(): String {
var stringUrl: String var stringUrl: String
stringUrl = if (link.contains("//news.google.com/news/") && link.contains("&amp;url=")) { stringUrl =
link.substringAfter("&amp;url=") if (link.contains("//news.google.com/news/") && link.contains("&amp;url=")) {
} else { link.substringAfter("&amp;url=")
this.link.replace("&amp;", "&") } else {
} this.link.replace("&amp;", "&")
}
// handle :443 => https // handle :443 => https
if (stringUrl.contains(":443")) { if (stringUrl.contains(":443")) {
@ -145,27 +146,36 @@ class SelfossModel {
return txt return txt
} }
fun sourceAuthorOnly(): String {
var txt = this.sourcetitle.getHtmlDecoded()
if (!this.author.isNullOrBlank()) {
txt += " (by ${this.author}) "
}
return txt
}
fun toggleStar(): Item { fun toggleStar(): Item {
this.starred = !this.starred this.starred = !this.starred
return this return this
} }
} }
// TODO: this seems to be super slow. // TODO: this seems to be super slow.
object TagsListSerializer : KSerializer<List<String>> { object TagsListSerializer : KSerializer<List<String>> {
override fun deserialize(decoder: Decoder): List<String> { override fun deserialize(decoder: Decoder): List<String> {
return when(val json = ((decoder as JsonDecoder).decodeJsonElement())) { return when (val json = ((decoder as JsonDecoder).decodeJsonElement())) {
is JsonArray -> json.toList().map { it.toString().replace("^\"|\"$".toRegex(), "") } is JsonArray -> json.toList().map { it.toString().replace("^\"|\"$".toRegex(), "") }
else -> json.toString().split(",") else -> json.toString().split(",")
} }
} }
override val descriptor: SerialDescriptor override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("tags", PrimitiveKind.STRING) get() = PrimitiveSerialDescriptor("tags", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: List<String>) { override fun serialize(
encoder: Encoder,
value: List<String>,
) {
encoder.encodeCollection(PrimitiveSerialDescriptor("tags", PrimitiveKind.STRING), value.size) { this.toString() } encoder.encodeCollection(PrimitiveSerialDescriptor("tags", PrimitiveKind.STRING), value.size) { this.toString() }
} }
} }
@ -181,9 +191,12 @@ class SelfossModel {
} }
override val descriptor: SerialDescriptor override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("b", PrimitiveKind.BOOLEAN) get() = PrimitiveSerialDescriptor("BooleanOrIntForSomeSelfossVersions", PrimitiveKind.BOOLEAN)
override fun serialize(encoder: Encoder, value: Boolean) { override fun serialize(
encoder: Encoder,
value: Boolean,
) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View File

@ -8,7 +8,6 @@ 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.utils.* import bou.amine.apps.readerforselfossv2.utils.*
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import io.ktor.client.call.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -19,9 +18,8 @@ class Repository(
private val api: SelfossApi, private val api: SelfossApi,
private val appSettingsService: AppSettingsService, private val appSettingsService: AppSettingsService,
val isConnectionAvailable: MutableStateFlow<Boolean>, val isConnectionAvailable: MutableStateFlow<Boolean>,
private val db: ReaderForSelfossDB private val db: ReaderForSelfossDB,
) { ) {
var items = ArrayList<SelfossModel.Item>() var items = ArrayList<SelfossModel.Item>()
var connectionMonitored = false var connectionMonitored = false
@ -53,20 +51,22 @@ 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 (isNetworkAvailable()) {
fetchedItems = api.getItems( fetchedItems =
displayedItems.type, api.getItems(
offset = 0, displayedItems.type,
tagFilter.value?.tag, offset = 0,
sourceFilter.value?.id?.toLong(), tagFilter.value?.tag,
searchFilter, sourceFilter.value?.id?.toLong(),
null searchFilter,
) null,
)
} else if (appSettingsService.isItemCachingEnabled()) { } else if (appSettingsService.isItemCachingEnabled()) {
var dbItems = getDBItems().filter { var dbItems =
displayedItems == ItemType.ALL || getDBItems().filter {
displayedItems == ItemType.ALL ||
(it.unread && displayedItems == ItemType.UNREAD) || (it.unread && displayedItems == ItemType.UNREAD) ||
(it.starred && displayedItems == ItemType.STARRED) (it.starred && displayedItems == ItemType.STARRED)
} }
if (tagFilter.value != null) { if (tagFilter.value != null) {
dbItems = dbItems.filter { it.tags.split(',').contains(tagFilter.value!!.tag) } dbItems = dbItems.filter { it.tags.split(',').contains(tagFilter.value!!.tag) }
} }
@ -74,10 +74,11 @@ class Repository(
dbItems = dbItems.filter { it.sourcetitle == sourceFilter.value!!.title } dbItems = dbItems.filter { it.sourcetitle == sourceFilter.value!!.title }
} }
val itemsList = ArrayList(dbItems.map { it.toView() }) val itemsList = ArrayList(dbItems.map { it.toView() })
itemsList.sortByDescending { DateUtils.parseDate(it.datetime) } itemsList.sortByDescending { it.datetime.toParsedDate() }
fetchedItems = StatusAndData.succes( fetchedItems =
itemsList StatusAndData.succes(
) itemsList,
)
} }
if (fetchedItems.success && fetchedItems.data != null) { if (fetchedItems.success && fetchedItems.data != null) {
@ -90,14 +91,15 @@ class Repository(
var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error() var fetchedItems: StatusAndData<List<SelfossModel.Item>> = StatusAndData.error()
if (isNetworkAvailable()) { if (isNetworkAvailable()) {
val offset = items.size val offset = items.size
fetchedItems = api.getItems( fetchedItems =
displayedItems.type, api.getItems(
offset, displayedItems.type,
tagFilter.value?.tag, offset,
sourceFilter.value?.id?.toLong(), tagFilter.value?.tag,
searchFilter, sourceFilter.value?.id?.toLong(),
null searchFilter,
) null,
)
} // When using the db cache, we load everything the first time, so there should be nothing more to load. } // When using the db cache, we load everything the first time, so there should be nothing more to load.
if (fetchedItems.success && fetchedItems.data != null) { if (fetchedItems.success && fetchedItems.data != null) {
@ -108,15 +110,16 @@ 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 (isNetworkAvailable()) {
val items = api.getItems( val items =
itemType.type, api.getItems(
0, itemType.type,
null, 0,
null, null,
null, null,
null, null,
200 null,
) 200,
)
return if (items.success && items.data != null) { return if (items.success && items.data != null) {
items.data items.data
} else { } else {
@ -374,7 +377,7 @@ class Repository(
title: String, title: String,
url: String, url: String,
spout: String, spout: String,
tags: String tags: String,
): Boolean { ): Boolean {
var response = false var response = false
if (isNetworkAvailable()) { if (isNetworkAvailable()) {
@ -384,7 +387,10 @@ class Repository(
return response return response
} }
suspend fun deleteSource(id: Int, title: String): Boolean { suspend fun deleteSource(
id: Int,
title: String,
): Boolean {
var success = false var success = false
if (isNetworkAvailable()) { if (isNetworkAvailable()) {
val response = api.deleteSource(id) val response = api.deleteSource(id)
@ -416,28 +422,25 @@ class Repository(
val response = api.login() val response = api.login()
result = response.isSuccess == true result = response.isSuccess == true
} catch (cause: Throwable) { } catch (cause: Throwable) {
Napier.e(cause.stackTraceToString(), tag = "RepositoryImpl.login") Napier.e("login failed", cause, tag = "RepositoryImpl.login")
} }
} }
return result return result
} }
suspend fun shouldBeSelfossInstance(): Pair<Boolean, Boolean> { suspend fun checkIfFetchFails(): Boolean {
var fetchFailed = true var fetchFailed = true
var showSelfossOnlyModal = false
if (isNetworkAvailable()) { if (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
fetchFailed = !api.getItemsWithoutCatch().success fetchFailed = !api.getItemsWithoutCatch().success
} catch (e: NoTransformationFoundException) {
showSelfossOnlyModal = true
} catch (e: Throwable) { } catch (e: Throwable) {
Napier.e(e.stackTraceToString(), tag = "RepositoryImpl.shouldBeSelfossInstance") Napier.e("checkIfFetchFails failed", e, tag = "RepositoryImpl.shouldBeSelfossInstance")
} }
} }
return Pair(fetchFailed, showSelfossOnlyModal) return fetchFailed
} }
suspend fun logout() { suspend fun logout() {
@ -448,7 +451,7 @@ class Repository(
Napier.e("Couldn't logout.", tag = "RepositoryImpl.logout") Napier.e("Couldn't logout.", tag = "RepositoryImpl.logout")
} }
} catch (cause: Throwable) { } catch (cause: Throwable) {
Napier.e(cause.stackTraceToString(), tag = "RepositoryImpl.logout") Napier.e("logout failed", cause, tag = "RepositoryImpl.logout")
} }
appSettingsService.clearAll() appSettingsService.clearAll()
} else { } else {
@ -456,7 +459,11 @@ class Repository(
} }
} }
fun refreshLoginInformation(url: String, login: String, password: String) { fun refreshLoginInformation(
url: String,
login: String,
password: String,
) {
appSettingsService.refreshLoginInformation(url, login, password) appSettingsService.refreshLoginInformation(url, login, password)
baseUrl = url baseUrl = url
api.refreshLoginInformation() api.refreshLoginInformation()
@ -474,9 +481,10 @@ class Repository(
// Check if we're accessing the instance in public mode // Check if we're accessing the instance in public mode
// This happens when auth and public mode are enabled but // This happens when auth and public mode are enabled but
// no credentials are provided to login // no credentials are provided to login
if (appSettingsService.getUserName().isEmpty() if (appSettingsService.getUserName().isEmpty() &&
&& fetchedInformation.data.getApiConfiguration().isAuthEnabled() fetchedInformation.data.getApiConfiguration().isAuthEnabled() &&
&& fetchedInformation.data.getApiConfiguration().isPublicModeEnabled()) { fetchedInformation.data.getApiConfiguration().isPublicModeEnabled()
) {
appSettingsService.updatePublicAccess(true) appSettingsService.updatePublicAccess(true)
} }
} }
@ -485,11 +493,9 @@ class Repository(
fun isNetworkAvailable() = isConnectionAvailable.value && !offlineOverride fun isNetworkAvailable() = isConnectionAvailable.value && !offlineOverride
private fun getDBActions(): List<ACTION> = private fun getDBActions(): List<ACTION> = db.actionsQueries.actions().executeAsList()
db.actionsQueries.actions().executeAsList()
private fun deleteDBAction(action: ACTION) = private fun deleteDBAction(action: ACTION) = db.actionsQueries.deleteAction(action.id)
db.actionsQueries.deleteAction(action.id)
private fun getDBTags(): List<TAG> = db.tagsQueries.tags().executeAsList() private fun getDBTags(): List<TAG> = db.tagsQueries.tags().executeAsList()
@ -530,9 +536,8 @@ class Repository(
read: Boolean = false, read: Boolean = false,
unread: Boolean = false, unread: Boolean = false,
starred: Boolean = false, starred: Boolean = false,
unstarred: Boolean = false unstarred: Boolean = false,
) = ) = db.actionsQueries.insertAction(articleid, read, unread, starred, unstarred)
db.actionsQueries.insertAction(articleid, read, unread, starred, unstarred)
private fun updateDBItem(item: SelfossModel.Item) = private fun updateDBItem(item: SelfossModel.Item) =
db.itemsQueries.updateItem( db.itemsQueries.updateItem(
@ -547,7 +552,7 @@ class Repository(
item.sourcetitle, item.sourcetitle,
item.tags.joinToString(","), item.tags.joinToString(","),
item.author, item.author,
item.id.toString() item.id.toString(),
) )
suspend fun tryToCacheItemsAndGetNewOnes(): List<SelfossModel.Item> { suspend fun tryToCacheItemsAndGetNewOnes(): List<SelfossModel.Item> {
@ -564,32 +569,38 @@ class Repository(
} }
suspend fun handleDBActions() { suspend fun handleDBActions() {
val actions: List<ACTION> = getDBActions() val actions: List<ACTION> = getDBActions()
actions.forEach { action -> actions.forEach { action ->
when { when {
action.read -> doAndReportOnFail( action.read ->
markAsReadById(action.articleid.toInt()), doAndReportOnFail(
action markAsReadById(action.articleid.toInt()),
) action,
action.unread -> doAndReportOnFail( )
unmarkAsReadById(action.articleid.toInt()), action.unread ->
action doAndReportOnFail(
) unmarkAsReadById(action.articleid.toInt()),
action.starred -> doAndReportOnFail( action,
starrById(action.articleid.toInt()), )
action action.starred ->
) doAndReportOnFail(
action.unstarred -> doAndReportOnFail( starrById(action.articleid.toInt()),
unstarrById(action.articleid.toInt()), action,
action )
) action.unstarred ->
doAndReportOnFail(
unstarrById(action.articleid.toInt()),
action,
)
} }
} }
} }
private fun doAndReportOnFail(result: Boolean, action: ACTION) { private fun doAndReportOnFail(
result: Boolean,
action: ACTION,
) {
if (result) { if (result) {
deleteDBAction(action) deleteDBAction(action)
} }
@ -626,4 +637,4 @@ class Repository(
fun getSelectedSource(): SelfossModel.SourceDetail? { fun getSelectedSource(): SelfossModel.SourceDetail? {
return _selectedSource return _selectedSource
} }
} }

View File

@ -11,25 +11,27 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class MercuryApi() { class MercuryApi() {
var client = createHttpClient() var client = createHttpClient()
private fun createHttpClient(): HttpClient { private fun createHttpClient(): HttpClient {
return HttpClient { return HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
install(HttpCache) install(HttpCache)
json(Json { json(
prettyPrint = true Json {
isLenient = true prettyPrint = true
ignoreUnknownKeys = true isLenient = true
}) ignoreUnknownKeys = true
},
)
} }
install(Logging) { install(Logging) {
logger = object : Logger { logger =
override fun log(message: String) { object : Logger {
Napier.d(message, tag = "LogMercuryCalls") override fun log(message: String) {
Napier.d(message, tag = "LogMercuryCalls")
}
} }
}
level = LogLevel.INFO level = LogLevel.INFO
} }
expectSuccess = false expectSuccess = false
@ -37,7 +39,9 @@ class MercuryApi() {
} }
suspend fun query(url: String): StatusAndData<MercuryModel.ParsedContent> = suspend fun query(url: String): StatusAndData<MercuryModel.ParsedContent> =
bodyOrFailure(client.get("https://amine-louveau.fr/parser.php") { bodyOrFailure(
parameter("link", url) client.get("https://amine-bouabdallaoui.fr/parser.php") {
}) parameter("link", url)
} },
)
}

View File

@ -10,7 +10,6 @@ import io.ktor.client.request.forms.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
suspend fun responseOrSuccessIf404(r: HttpResponse?): SuccessResponse { suspend fun responseOrSuccessIf404(r: HttpResponse?): SuccessResponse {
return if (r != null && r.status === HttpStatusCode.NotFound) { return if (r != null && r.status === HttpStatusCode.NotFound) {
SuccessResponse(true) SuccessResponse(true)
@ -40,7 +39,7 @@ suspend inline fun <reified T> bodyOrFailure(r: HttpResponse?): StatusAndData<T>
inline fun tryToRequest( inline fun tryToRequest(
requestType: String, requestType: String,
fn: () -> HttpResponse fn: () -> HttpResponse,
): HttpResponse? { ): HttpResponse? {
var response: HttpResponse? = null var response: HttpResponse? = null
try { try {
@ -53,30 +52,46 @@ inline fun tryToRequest(
suspend inline fun HttpClient.tryToGet( suspend inline fun HttpClient.tryToGet(
urlString: String, urlString: String,
crossinline block: HttpRequestBuilder.() -> Unit = {} crossinline block: HttpRequestBuilder.() -> Unit = {},
): HttpResponse? = tryToRequest("Get") { return this.get { url(urlString); block() } } ): HttpResponse? =
tryToRequest("Get") {
return this.get {
url(urlString)
block()
}
}
suspend inline fun HttpClient.tryToPost( suspend inline fun HttpClient.tryToPost(
urlString: String, urlString: String,
block: HttpRequestBuilder.() -> Unit = {} block: HttpRequestBuilder.() -> Unit = {},
): HttpResponse? = tryToRequest("Post") { return this.post { url(urlString); block() } } ): HttpResponse? =
tryToRequest("Post") {
return this.post {
url(urlString)
block()
}
}
suspend inline fun HttpClient.tryToDelete( suspend inline fun HttpClient.tryToDelete(
urlString: String, urlString: String,
block: HttpRequestBuilder.() -> Unit = {} block: HttpRequestBuilder.() -> Unit = {},
): HttpResponse? = tryToRequest("Delete") { return this.delete { url(urlString); block() } } ): HttpResponse? =
tryToRequest("Delete") {
return this.delete {
url(urlString)
block()
}
}
suspend fun HttpClient.tryToSubmitForm( suspend fun HttpClient.tryToSubmitForm(
url: String, url: String,
formParameters: Parameters = Parameters.Empty, formParameters: Parameters = Parameters.Empty,
encodeInQuery: Boolean = false, encodeInQuery: Boolean = false,
block: HttpRequestBuilder.() -> Unit = {} block: HttpRequestBuilder.() -> Unit = {},
): HttpResponse? = ): HttpResponse? =
tryToRequest("SubmitForm") { tryToRequest("SubmitForm") {
return this.submitForm(formParameters, encodeInQuery) { return this.submitForm(formParameters, encodeInQuery) {
url(url) url(url)
block() block()
} }
} }

View File

@ -36,8 +36,8 @@ import kotlinx.serialization.json.Json
expect fun setupInsecureHTTPEngine(config: CIOEngineConfig) expect fun setupInsecureHTTPEngine(config: CIOEngineConfig)
class SelfossApi(private val appSettingsService: AppSettingsService) { class SelfossApi(private val appSettingsService: AppSettingsService) {
var client = createHttpClient() var client = createHttpClient()
fun createHttpClient() = fun createHttpClient() =
HttpClient(CIO) { HttpClient(CIO) {
if (appSettingsService.getSelfSigned()) { if (appSettingsService.getSelfSigned()) {
@ -47,19 +47,22 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
} }
install(ContentNegotiation) { install(ContentNegotiation) {
install(HttpCache) install(HttpCache)
json(Json { json(
prettyPrint = true Json {
isLenient = true prettyPrint = true
ignoreUnknownKeys = true isLenient = true
explicitNulls = false ignoreUnknownKeys = true
}) explicitNulls = false
},
)
} }
install(Logging) { install(Logging) {
logger = object : Logger { logger =
override fun log(message: String) { object : Logger {
Napier.d(message, tag = "LogApiCalls") override fun log(message: String) {
Napier.d(message, tag = "LogApiCalls")
}
} }
}
level = LogLevel.INFO level = LogLevel.INFO
} }
install(HttpTimeout) { install(HttpTimeout) {
@ -83,8 +86,7 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
expectSuccess = false expectSuccess = false
} }
fun url(path: String) = fun url(path: String) = "${appSettingsService.getBaseUrl()}$path"
"${appSettingsService.getBaseUrl()}$path"
fun refreshLoginInformation() { fun refreshLoginInformation() {
appSettingsService.refreshApiSettings() appSettingsService.refreshApiSettings()
@ -100,12 +102,15 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
// Api version was introduces after the POST login, so when there is a version, it should be available // Api version was introduces after the POST login, so when there is a version, it should be available
private fun shouldHavePostLogin() = appSettingsService.getApiVersion() != -1 private fun shouldHavePostLogin() = appSettingsService.getApiVersion() != -1
private fun hasLoginInfo() = private fun hasLoginInfo() =
appSettingsService.getUserName().isNotEmpty() && appSettingsService.getPassword() appSettingsService.getUserName().isNotEmpty() &&
.isNotEmpty() appSettingsService.getPassword()
.isNotEmpty()
suspend fun login(): SuccessResponse = suspend fun login(): SuccessResponse =
if (appSettingsService.getUserName().isNotEmpty() && appSettingsService.getPassword() if (appSettingsService.getUserName().isNotEmpty() &&
appSettingsService.getPassword()
.isNotEmpty() .isNotEmpty()
) { ) {
if (shouldHavePostLogin()) { if (shouldHavePostLogin()) {
@ -117,30 +122,49 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
SuccessResponse(true) SuccessResponse(true)
} }
private suspend fun getLogin() = maybeResponse(client.tryToGet(url("/login")) { private suspend fun getLogin() =
parameter("username", appSettingsService.getUserName()) maybeResponse(
parameter("password", appSettingsService.getPassword()) client.tryToGet(url("/login")) {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { parameter("username", appSettingsService.getUserName())
headers { parameter("password", appSettingsService.getPassword())
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
) headers {
} append(
} HttpHeaders.Authorization,
}) constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
private suspend fun postLogin() = maybeResponse(client.tryToPost(url("/login")) { private suspend fun postLogin() =
parameter("username", appSettingsService.getUserName()) maybeResponse(
parameter("password", appSettingsService.getPassword()) client.tryToPost(url("/login")) {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { parameter("username", appSettingsService.getUserName())
headers { parameter("password", appSettingsService.getPassword())
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
) headers {
} append(
} HttpHeaders.Authorization,
}) constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
private fun shouldHaveNewLogout() = private fun shouldHaveNewLogout() = appSettingsService.getApiVersion() >= 5 // We are missing 4.1.0
appSettingsService.getApiVersion() >= 5 // We are missing 4.1.0
suspend fun logout(): SuccessResponse = suspend fun logout(): SuccessResponse =
if (shouldHaveNewLogout()) { if (shouldHaveNewLogout()) {
@ -150,23 +174,42 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
} }
private suspend fun maybeLogoutIfAvailable() = private suspend fun maybeLogoutIfAvailable() =
responseOrSuccessIf404(client.tryToGet(url("/logout")) { responseOrSuccessIf404(
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { client.tryToGet(url("/logout")) {
headers { if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) headers {
) append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
} }
} },
}) )
private suspend fun doLogout() = maybeResponse(client.tryToDelete(url("/api/session/current")) { private suspend fun doLogout() =
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { maybeResponse(
headers { client.tryToDelete(url("/api/session/current")) {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
) headers {
} append(
} HttpHeaders.Authorization,
}) constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun getItems( suspend fun getItems(
type: String, type: String,
@ -175,213 +218,340 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
source: Long?, source: Long?,
search: String?, search: String?,
updatedSince: String?, updatedSince: String?,
items: Int? = null items: Int? = null,
): StatusAndData<List<SelfossModel.Item>> = ): StatusAndData<List<SelfossModel.Item>> =
bodyOrFailure(client.tryToGet(url("/items")) { bodyOrFailure(
if (!shouldHavePostLogin()) { client.tryToGet(url("/items")) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
parameter("type", type)
parameter("tag", tag)
parameter("source", source)
parameter("search", search)
parameter("updatedsince", updatedSince)
parameter("items", items ?: appSettingsService.getItemsNumber())
parameter("offset", offset)
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun getItemsWithoutCatch(): StatusAndData<List<SelfossModel.Item>> =
bodyOrFailure(client.get(url("/items")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
parameter("type", "all")
parameter("items", 1)
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun stats(): StatusAndData<SelfossModel.Stats> =
bodyOrFailure(client.tryToGet(url("/stats")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun tags(): StatusAndData<List<SelfossModel.Tag>> =
bodyOrFailure(client.tryToGet(url("/tags")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun update(): StatusAndData<String> =
bodyOrFailure(client.tryToGet(url("/update")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun spouts(): StatusAndData<Map<String, SelfossModel.Spout>> =
bodyOrFailure(client.tryToGet(url("/sources/spouts")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun sourcesStats(): StatusAndData<ArrayList<SelfossModel.SourceStats>> =
bodyOrFailure(client.tryToGet(url("/sources/stats")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun sourcesDetailed(): StatusAndData<ArrayList<SelfossModel.SourceDetail>> =
bodyOrFailure(client.tryToGet(url("/sources/list")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun apiInformation(): StatusAndData<SelfossModel.ApiInformation> =
bodyOrFailure(client.tryToGet(url("/api/about")) {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun markAsRead(id: String): SuccessResponse =
maybeResponse(client.tryToPost(url("/mark/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun unmarkAsRead(id: String): SuccessResponse =
maybeResponse(client.tryToPost(url("/unmark/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun starr(id: String): SuccessResponse =
maybeResponse(client.tryToPost(url("/starr/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun unstarr(id: String): SuccessResponse =
maybeResponse(client.tryToPost(url("/unstarr/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword()))
)
}
}
})
suspend fun markAllAsRead(ids: List<String>): SuccessResponse =
maybeResponse(client.tryToSubmitForm(
url = url("/mark"),
formParameters = Parameters.build {
if (!shouldHavePostLogin()) { if (!shouldHavePostLogin()) {
append("username", appSettingsService.getUserName()) parameter("username", appSettingsService.getUserName())
append("password", appSettingsService.getPassword()) parameter("password", appSettingsService.getPassword())
} }
ids.map { append("ids[]", it) } parameter("type", type)
}, parameter("tag", tag)
block = { parameter("source", source)
parameter("search", search)
parameter("updatedsince", updatedSince)
parameter("items", items ?: appSettingsService.getItemsNumber())
parameter("offset", offset)
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers { headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
) )
} }
} }
} },
)) )
suspend fun getItemsWithoutCatch(): StatusAndData<List<SelfossModel.Item>> =
bodyOrFailure(
client.get(url("/items")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
parameter("type", "all")
parameter("items", 1)
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun stats(): StatusAndData<SelfossModel.Stats> =
bodyOrFailure(
client.tryToGet(url("/stats")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun tags(): StatusAndData<List<SelfossModel.Tag>> =
bodyOrFailure(
client.tryToGet(url("/tags")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun update(): StatusAndData<String> =
bodyOrFailure(
client.tryToGet(url("/update")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun spouts(): StatusAndData<Map<String, SelfossModel.Spout>> =
bodyOrFailure(
client.tryToGet(url("/sources/spouts")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun sourcesStats(): StatusAndData<ArrayList<SelfossModel.SourceStats>> =
bodyOrFailure(
client.tryToGet(url("/sources/stats")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun sourcesDetailed(): StatusAndData<ArrayList<SelfossModel.SourceDetail>> =
bodyOrFailure(
client.tryToGet(url("/sources/list")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun apiInformation(): StatusAndData<SelfossModel.ApiInformation> =
bodyOrFailure(
client.tryToGet(url("/api/about")) {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun markAsRead(id: String): SuccessResponse =
maybeResponse(
client.tryToPost(url("/mark/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun unmarkAsRead(id: String): SuccessResponse =
maybeResponse(
client.tryToPost(url("/unmark/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun starr(id: String): SuccessResponse =
maybeResponse(
client.tryToPost(url("/starr/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun unstarr(id: String): SuccessResponse =
maybeResponse(
client.tryToPost(url("/unstarr/$id")) {
if (!shouldHavePostLogin()) {
parameter("username", appSettingsService.getUserName())
parameter("password", appSettingsService.getPassword())
}
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
suspend fun markAllAsRead(ids: List<String>): SuccessResponse =
maybeResponse(
client.tryToSubmitForm(
url = url("/mark"),
formParameters =
Parameters.build {
if (!shouldHavePostLogin()) {
append("username", appSettingsService.getUserName())
append("password", appSettingsService.getPassword())
}
ids.map { append("ids[]", it) }
},
block = {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
),
)
suspend fun createSourceForVersion( suspend fun createSourceForVersion(
title: String, title: String,
@ -394,7 +564,7 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
createSource("tags[]", title, url, spout, tags) createSource("tags[]", title, url, spout, tags)
} else { } else {
createSource("tags", title, url, spout, tags) createSource("tags", title, url, spout, tags)
} },
) )
private suspend fun createSource( private suspend fun createSource(
@ -402,28 +572,36 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
title: String, title: String,
url: String, url: String,
spout: String, spout: String,
tags: String tags: String,
): HttpResponse? = ): HttpResponse? =
client.tryToSubmitForm( client.tryToSubmitForm(
url = url("/source"), url = url("/source"),
formParameters = Parameters.build { formParameters =
if (!shouldHavePostLogin()) { Parameters.build {
append("username", appSettingsService.getUserName()) if (!shouldHavePostLogin()) {
append("password", appSettingsService.getPassword()) append("username", appSettingsService.getUserName())
} append("password", appSettingsService.getPassword())
append("title", title) }
append("url", url) append("title", title)
append("spout", spout) append("url", url)
append(tagsParamName, tags) append("spout", spout)
}, append(tagsParamName, tags)
},
block = { block = {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers { headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
) )
} }
} }
} },
) )
suspend fun updateSourceForVersion( suspend fun updateSourceForVersion(
@ -431,14 +609,14 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
title: String, title: String,
url: String, url: String,
spout: String, spout: String,
tags: String tags: String,
): SuccessResponse = ): SuccessResponse =
maybeResponse( maybeResponse(
if (appSettingsService.getApiVersion() > 1) { if (appSettingsService.getApiVersion() > 1) {
updateSource(id, "tags[]", title, url, spout, tags) updateSource(id, "tags[]", title, url, spout, tags)
} else { } else {
updateSource(id, "tags", title, url, spout, tags) updateSource(id, "tags", title, url, spout, tags)
} },
) )
private suspend fun updateSource( private suspend fun updateSource(
@ -451,44 +629,54 @@ class SelfossApi(private val appSettingsService: AppSettingsService) {
): HttpResponse? = ): HttpResponse? =
client.tryToSubmitForm( client.tryToSubmitForm(
url = url("/source/$id"), url = url("/source/$id"),
formParameters = Parameters.build { formParameters =
if (!shouldHavePostLogin()) { Parameters.build {
append("username", appSettingsService.getUserName()) if (!shouldHavePostLogin()) {
append("password", appSettingsService.getPassword()) append("username", appSettingsService.getUserName())
} append("password", appSettingsService.getPassword())
append("title", title) }
append("url", url) append("title", title)
append("spout", spout) append("url", url)
append(tagsParamName, tags) append("spout", spout)
}, append(tagsParamName, tags)
},
block = { block = {
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) { if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers { headers {
append(HttpHeaders.Authorization, constructBasicAuthValue(BasicAuthCredentials(username = appSettingsService.getBasicUserName(), password = appSettingsService.getBasicPassword())) append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
) )
} }
} }
} },
) )
suspend fun deleteSource(id: Int): SuccessResponse = suspend fun deleteSource(id: Int): SuccessResponse =
maybeResponse(client.tryToDelete(url("/source/$id")) { maybeResponse(
if (!shouldHavePostLogin()) { client.tryToDelete(url("/source/$id")) {
parameter("username", appSettingsService.getUserName()) if (!shouldHavePostLogin()) {
parameter("password", appSettingsService.getPassword()) parameter("username", appSettingsService.getUserName())
} parameter("password", appSettingsService.getPassword())
if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
headers {
append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword()
)
)
)
} }
} if (appSettingsService.getBasicUserName().isNotEmpty() && appSettingsService.getBasicPassword().isNotEmpty()) {
}) headers {
} append(
HttpHeaders.Authorization,
constructBasicAuthValue(
BasicAuthCredentials(
username = appSettingsService.getBasicUserName(),
password = appSettingsService.getBasicPassword(),
),
),
)
}
}
},
)
}

View File

@ -6,65 +6,119 @@ import com.russhwolf.settings.Settings
// This is to fix ACRA not sending reports anymore. // This is to fix ACRA not sending reports anymore.
// See https://www.acra.ch/docs/Troubleshooting-Guide#applicationoncreate // See https://www.acra.ch/docs/Troubleshooting-Guide#applicationoncreate
class ACRASettings : Settings { class ACRASettings : Settings {
override val keys: Set<String> = emptySet() override val keys: MutableSet<String> = mutableSetOf()
override val size: Int = 0 override val size: Int = 0
val bools: MutableMap<String, Boolean> = mutableMapOf()
val doubles: MutableMap<String, Double> = mutableMapOf()
val floats: MutableMap<String, Float> = mutableMapOf()
val ints: MutableMap<String, Int> = mutableMapOf()
val longs: MutableMap<String, Long> = mutableMapOf()
val strings: MutableMap<String, String> = mutableMapOf()
override fun clear() { override fun clear() {
// Nothing // Nothing
} }
override fun getBoolean(key: String, defaultValue: Boolean): Boolean = false override fun getBoolean(
key: String,
defaultValue: Boolean,
): Boolean = bools[key] ?: defaultValue
override fun getBooleanOrNull(key: String): Boolean? = null override fun getBooleanOrNull(key: String): Boolean? = bools[key]
override fun getDouble(key: String, defaultValue: Double): Double = 0.0 override fun getDouble(
key: String,
defaultValue: Double,
): Double = doubles[key] ?: defaultValue
override fun getDoubleOrNull(key: String): Double? = null override fun getDoubleOrNull(key: String): Double? = doubles[key]
override fun getFloat(key: String, defaultValue: Float): Float = 0.0F override fun getFloat(
key: String,
defaultValue: Float,
): Float = floats[key] ?: defaultValue
override fun getFloatOrNull(key: String): Float? = null override fun getFloatOrNull(key: String): Float? = floats[key]
override fun getInt(key: String, defaultValue: Int): Int = 0 override fun getInt(
key: String,
defaultValue: Int,
): Int = ints[key] ?: defaultValue
override fun getIntOrNull(key: String): Int? = null override fun getIntOrNull(key: String): Int? = ints[key]
override fun getLong(key: String, defaultValue: Long): Long = 0 override fun getLong(
key: String,
defaultValue: Long,
): Long = longs[key] ?: defaultValue
override fun getLongOrNull(key: String): Long? = null override fun getLongOrNull(key: String): Long? = longs[key]
override fun getString(key: String, defaultValue: String): String = "0" override fun getString(
key: String,
defaultValue: String,
): String = strings[key] ?: defaultValue
override fun getStringOrNull(key: String): String? = null override fun getStringOrNull(key: String): String? = strings[key]
override fun hasKey(key: String): Boolean = false override fun hasKey(key: String): Boolean = keys.contains(key)
override fun putBoolean(key: String, value: Boolean) { override fun putBoolean(
// Nothing key: String,
value: Boolean,
) {
keys.add(key)
bools[key] = value
} }
override fun putDouble(key: String, value: Double) { override fun putDouble(
// Nothing key: String,
value: Double,
) {
keys.add(key)
doubles[key] = value
} }
override fun putFloat(key: String, value: Float) { override fun putFloat(
// Nothing key: String,
value: Float,
) {
keys.add(key)
floats[key] = value
} }
override fun putInt(key: String, value: Int) { override fun putInt(
// Nothing key: String,
value: Int,
) {
keys.add(key)
ints[key] = value
} }
override fun putLong(key: String, value: Long) { override fun putLong(
// Nothing key: String,
value: Long,
) {
keys.add(key)
longs[key] = value
} }
override fun putString(key: String, value: String) { override fun putString(
// Nothing key: String,
value: String,
) {
keys.add(key)
strings[key] = value
} }
override fun remove(key: String) { override fun remove(key: String) {
// Nothing keys.remove(key)
bools.remove(key)
doubles.remove(key)
floats.remove(key)
ints.remove(key)
longs.remove(key)
strings.remove(key)
} }
} }

View File

@ -3,7 +3,12 @@ package bou.amine.apps.readerforselfossv2.service
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
class AppSettingsService(acraSenderServiceProcess: Boolean = false) { class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
val settings: Settings = if (acraSenderServiceProcess) { ACRASettings() } else { Settings() } val settings: Settings =
if (acraSenderServiceProcess) {
ACRASettings()
} else {
Settings()
}
// Api related // Api related
private var _apiVersion: Int = -1 private var _apiVersion: Int = -1
@ -38,7 +43,6 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
private var _font: String = "" private var _font: String = ""
private var _theme: Int? = null private var _theme: Int? = null
init { init {
refreshApiSettings() refreshApiSettings()
refreshUserSettings() refreshUserSettings()
@ -52,7 +56,6 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
return _apiVersion return _apiVersion
} }
fun updateApiVersion(apiMajorVersion: Int) { fun updateApiVersion(apiMajorVersion: Int) {
settings.putInt(API_VERSION_MAJOR, apiMajorVersion) settings.putInt(API_VERSION_MAJOR, apiMajorVersion)
refreshApiVersion() refreshApiVersion()
@ -137,13 +140,13 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
private fun refreshItemsNumber() { private fun refreshItemsNumber() {
_itemsNumber = try { _itemsNumber =
settings.getString(API_ITEMS_NUMBER, "20").toInt() try {
} catch (e: Exception) { settings.getString(API_ITEMS_NUMBER, "20").toInt()
settings.remove(API_ITEMS_NUMBER) } catch (e: Exception) {
20 settings.remove(API_ITEMS_NUMBER)
} 20
}
} }
fun getApiTimeout(): Long { fun getApiTimeout(): Long {
@ -156,18 +159,21 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
private fun secToMs(n: Long) = n * 1000 private fun secToMs(n: Long) = n * 1000
private fun refreshApiTimeout() { private fun refreshApiTimeout() {
_apiTimeout = secToMs(try { _apiTimeout =
val settingsTimeout = settings.getString(API_TIMEOUT, "60") secToMs(
if (settingsTimeout.toLong() > 0) { try {
settingsTimeout.toLong() val settingsTimeout = settings.getString(API_TIMEOUT, "60")
} else { if (settingsTimeout.toLong() > 0) {
settings.remove(API_TIMEOUT) settingsTimeout.toLong()
60 } else {
} settings.remove(API_TIMEOUT)
} catch (e: Exception) { 60
settings.remove(API_TIMEOUT) }
60 } catch (e: Exception) {
}) settings.remove(API_TIMEOUT)
60
},
)
} }
private fun refreshBaseUrl() { private fun refreshBaseUrl() {
@ -200,6 +206,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _articleViewer == true return _articleViewer == true
} }
private fun refreshShouldBeCardViewEnabled() { private fun refreshShouldBeCardViewEnabled() {
_shouldBeCardView = settings.getBoolean(CARD_VIEW_ACTIVE, false) _shouldBeCardView = settings.getBoolean(CARD_VIEW_ACTIVE, false)
} }
@ -210,6 +217,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _shouldBeCardView == true return _shouldBeCardView == true
} }
private fun refreshDisplayUnreadCountEnabled() { private fun refreshDisplayUnreadCountEnabled() {
_displayUnreadCount = settings.getBoolean(DISPLAY_UNREAD_COUNT, true) _displayUnreadCount = settings.getBoolean(DISPLAY_UNREAD_COUNT, true)
} }
@ -220,6 +228,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _displayUnreadCount == true return _displayUnreadCount == true
} }
private fun refreshDisplayAllCountEnabled() { private fun refreshDisplayAllCountEnabled() {
_displayAllCount = settings.getBoolean(DISPLAY_OTHER_COUNT, false) _displayAllCount = settings.getBoolean(DISPLAY_OTHER_COUNT, false)
} }
@ -230,6 +239,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _displayAllCount == true return _displayAllCount == true
} }
private fun refreshFullHeightCardsEnabled() { private fun refreshFullHeightCardsEnabled() {
_fullHeightCards = settings.getBoolean(FULL_HEIGHT_CARDS, false) _fullHeightCards = settings.getBoolean(FULL_HEIGHT_CARDS, false)
} }
@ -240,6 +250,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _fullHeightCards == true return _fullHeightCards == true
} }
private fun refreshUpdateSourcesEnabled() { private fun refreshUpdateSourcesEnabled() {
_updateSources = settings.getBoolean(UPDATE_SOURCES, true) _updateSources = settings.getBoolean(UPDATE_SOURCES, true)
} }
@ -250,6 +261,7 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
} }
return _updateSources == true return _updateSources == true
} }
private fun refreshPeriodicRefreshEnabled() { private fun refreshPeriodicRefreshEnabled() {
_periodicRefresh = settings.getBoolean(PERIODIC_REFRESH, false) _periodicRefresh = settings.getBoolean(PERIODIC_REFRESH, false)
} }
@ -319,7 +331,6 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
return _notifyNewItems == true return _notifyNewItems == true
} }
private fun refreshMarkOnScrollEnabled() { private fun refreshMarkOnScrollEnabled() {
_markOnScroll = settings.getBoolean(MARK_ON_SCROLL, false) _markOnScroll = settings.getBoolean(MARK_ON_SCROLL, false)
} }
@ -331,7 +342,6 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
return _markOnScroll == true return _markOnScroll == true
} }
private fun refreshActiveAllignment() { private fun refreshActiveAllignment() {
_activeAlignment = settings.getInt(TEXT_ALIGN, JUSTIFY) _activeAlignment = settings.getInt(TEXT_ALIGN, JUSTIFY)
} }
@ -429,9 +439,9 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
fun refreshLoginInformation( fun refreshLoginInformation(
url: String, url: String,
login: String, login: String,
password: String password: String,
) { ) {
val regex = """\/\/(\D+):(\D+)@""".toRegex() val regex = """\/\/(\S+):(\S+)@""".toRegex()
val matchResult = regex.find(url) val matchResult = regex.find(url)
if (matchResult != null) { if (matchResult != null) {
val (basicLogin, basicPassword) = matchResult.destructured val (basicLogin, basicPassword) = matchResult.destructured
@ -470,9 +480,9 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
companion object { companion object {
const val translationUrl = "https://crwd.in/readerforselfoss" const val translationUrl = "https://crwd.in/readerforselfoss"
const val sourceUrl = "https://gitea.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform" const val sourceUrl = "https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform"
const val trackerUrl = "https://gitea.amine-louveau.fr/Louvorg/ReaderForSelfoss-multiplatform/issues" const val trackerUrl = "https://gitea.amine-bouabdallaoui.fr/Louvorg/ReaderForSelfoss-multiplatform/issues"
const val syncChannelId = "sync-channel-id" const val syncChannelId = "sync-channel-id"
@ -537,6 +547,5 @@ class AppSettingsService(acraSenderServiceProcess: Boolean = false) {
const val ITEMS_CACHING = "items_caching" const val ITEMS_CACHING = "items_caching"
const val CURRENT_THEME = "currentMode" const val CURRENT_THEME = "currentMode"
} }
} }

View File

@ -1,9 +1,35 @@
package bou.amine.apps.readerforselfossv2.utils package bou.amine.apps.readerforselfossv2.utils
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
fun String.toParsedDate(): Long {
// Possible formats are
// yyyy-mm-dd hh:mm:ss format
val oldVersionFormat = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(.()\\d*)?".toRegex()
// yyyy-MM-dd'T'HH:mm:ss[.SSS]XXX (RFC3339)
val newVersionFormat = "(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})[+-](\\d{2}(:\\d{2})?)?".toRegex()
val isoDateString: String =
try {
if (this.matches(oldVersionFormat)) {
this.replace(" ", "T")
} else if (this.matches(newVersionFormat)) {
newVersionFormat.find(this)?.groups?.get(1)?.value ?: throw Exception("Couldn't parse $this")
} else {
throw Exception("Unrecognized format for $this")
}
} catch (e: Exception) {
throw Exception("parseDate failed for $this", e)
}
return LocalDateTime.parse(isoDateString).toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()
}
expect class DateUtils() { expect class DateUtils() {
companion object { companion object {
fun parseDate(dateString: String): Long
fun parseRelativeDate(dateString: String): String fun parseRelativeDate(dateString: String): String
} }
} }

View File

@ -9,7 +9,7 @@ fun TAG.toView(): SelfossModel.Tag =
SelfossModel.Tag( SelfossModel.Tag(
this.name, this.name,
this.color, this.color,
this.unread.toInt() this.unread.toInt(),
) )
fun SOURCE.toView(): SelfossModel.SourceDetail = fun SOURCE.toView(): SelfossModel.SourceDetail =
@ -21,7 +21,7 @@ fun SOURCE.toView(): SelfossModel.SourceDetail =
this.spout, this.spout,
this.error, this.error,
this.icon, this.icon,
if (this.url != null) SelfossModel.SourceParams(this.url) else null if (this.url != null) SelfossModel.SourceParams(this.url) else null,
) )
fun SelfossModel.SourceDetail.toEntity(): SOURCE = fun SelfossModel.SourceDetail.toEntity(): SOURCE =
@ -32,14 +32,14 @@ fun SelfossModel.SourceDetail.toEntity(): SOURCE =
this.spout.orEmpty(), this.spout.orEmpty(),
this.error.orEmpty(), this.error.orEmpty(),
this.icon.orEmpty(), this.icon.orEmpty(),
this.params?.url this.params?.url,
) )
fun SelfossModel.Tag.toEntity(): TAG = fun SelfossModel.Tag.toEntity(): TAG =
TAG( TAG(
this.tag, this.tag,
this.color, this.color,
this.unread.toLong() this.unread.toLong(),
) )
fun ITEM.toView(): SelfossModel.Item = fun ITEM.toView(): SelfossModel.Item =
@ -55,7 +55,7 @@ fun ITEM.toView(): SelfossModel.Item =
this.link, this.link,
this.sourcetitle, this.sourcetitle,
this.tags.split(","), this.tags.split(","),
this.author this.author,
) )
fun SelfossModel.Item.toEntity(): ITEM = fun SelfossModel.Item.toEntity(): ITEM =
@ -71,5 +71,15 @@ fun SelfossModel.Item.toEntity(): ITEM =
this.link, this.link,
this.sourcetitle.getHtmlDecoded(), this.sourcetitle.getHtmlDecoded(),
this.tags.joinToString(","), this.tags.joinToString(","),
this.author this.author,
) )
fun SelfossModel.Tag.getColorHexCode(): String =
if (this.color.length == 4) { // #000
val char1 = this.color.get(1)
val char2 = this.color.get(2)
val char3 = this.color.get(3)
"#$char1$char1$char2$char2$char3$char3"
} else {
this.color
}

View File

@ -3,9 +3,10 @@ package bou.amine.apps.readerforselfossv2.utils
enum class ItemType(val position: Int, val type: String) { enum class ItemType(val position: Int, val type: String) {
UNREAD(1, "unread"), UNREAD(1, "unread"),
ALL(2, "all"), ALL(2, "all"),
STARRED(3, "starred"); STARRED(3, "starred"),
;
companion object { companion object {
fun fromInt(value: Int) = values().first { it.position == value } fun fromInt(value: Int) = values().first { it.position == value }
} }
} }

View File

@ -12,4 +12,8 @@ expect fun SelfossModel.Item.getImages(): ArrayList<String>
expect fun SelfossModel.Source.getIcon(baseUrl: String): String expect fun SelfossModel.Source.getIcon(baseUrl: String): String
expect fun constructUrl(baseUrl: String, path: String, file: String?): String expect fun constructUrl(
baseUrl: String,
path: String,
file: String?,
): String

View File

@ -1,7 +1,6 @@
package bou.amine.apps.readerforselfossv2.utils package bou.amine.apps.readerforselfossv2.utils
fun String?.isEmptyOrNullOrNullString(): Boolean = fun String?.isEmptyOrNullOrNullString(): Boolean = this == null || this == "null" || this.isEmpty()
this == null || this == "null" || this.isEmpty()
fun String.longHash(): Long { fun String.longHash(): Long {
var h = 98764321261L var h = 98764321261L
@ -19,4 +18,4 @@ fun String.toStringUriWithHttp(): String =
"http://" + this "http://" + this
} else { } else {
this this
} }

View File

@ -1,22 +1,32 @@
package bou.amine.apps.readerforselfossv2.repository package bou.amine.apps.readerforselfossv2.repository
import bou.amine.apps.readerforselfossv2.utils.DateUtils import bou.amine.apps.readerforselfossv2.utils.toParsedDate
import junit.framework.TestCase.assertEquals
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import org.junit.Test import kotlin.test.Test
import kotlin.test.assertEquals
class DatesTest { class DatesTest {
private val newVersionDateVariant = "2022-12-24T17:00:08+00"
private val newVersionDate = "2013-04-07T13:43:00+01:00"
private val newVersionDate2 = "2013-04-07T13:43:00-01:00"
private val oldVersionDate = "2013-05-07 13:46:00"
private val oldVersionDateVariant = "2021-03-21 10:32:00.000000"
private val newVersionDateVariant = "2022-12-24T17:00:08+00"
private val newVersionDate = "2013-04-07T13:43:00+01:00"
private val oldVersionDate = "2013-05-07 13:46:00"
private val oldVersionDateVariant = "2021-03-21 10:32:00.000000"
@Test @Test
fun new_version_date_should_be_parsed() { fun new_version_date_should_be_parsed() {
val date = DateUtils.parseDate(newVersionDate) val date = newVersionDate.toParsedDate()
val expected =
LocalDateTime(2013, 4, 7, 13, 43, 0, 0).toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
assertEquals(expected, date)
}
@Test
fun new_version_date2_should_be_parsed() {
val date = newVersionDate2.toParsedDate()
val expected = val expected =
LocalDateTime(2013, 4, 7, 13, 43, 0, 0).toInstant(TimeZone.currentSystemDefault()) LocalDateTime(2013, 4, 7, 13, 43, 0, 0).toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds() .toEpochMilliseconds()
@ -26,7 +36,7 @@ class DatesTest {
@Test @Test
fun old_version_date_should_be_parsed() { fun old_version_date_should_be_parsed() {
val date = DateUtils.parseDate(oldVersionDate) val date = oldVersionDate.toParsedDate()
val expected = val expected =
LocalDateTime(2013, 5, 7, 13, 46, 0, 0).toInstant(TimeZone.currentSystemDefault()) LocalDateTime(2013, 5, 7, 13, 46, 0, 0).toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds() .toEpochMilliseconds()
@ -36,7 +46,7 @@ class DatesTest {
@Test @Test
fun old_version_variant_date_should_be_parsed() { fun old_version_variant_date_should_be_parsed() {
val date = DateUtils.parseDate(oldVersionDateVariant) val date = oldVersionDateVariant.toParsedDate()
val expected = val expected =
LocalDateTime(2021, 3, 21, 10, 32, 0, 0).toInstant(TimeZone.currentSystemDefault()) LocalDateTime(2021, 3, 21, 10, 32, 0, 0).toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds() .toEpochMilliseconds()
@ -46,12 +56,11 @@ class DatesTest {
@Test @Test
fun new_version_variant_date_should_be_parsed() { fun new_version_variant_date_should_be_parsed() {
val date = DateUtils.parseDate(newVersionDateVariant) val date = newVersionDateVariant.toParsedDate()
val expected = val expected =
LocalDateTime(2022, 12, 24, 17, 0, 8, 0).toInstant(TimeZone.currentSystemDefault()) LocalDateTime(2022, 12, 24, 17, 0, 8, 0).toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds() .toEpochMilliseconds()
assertEquals(expected, date) assertEquals(expected, date)
} }
} }

View File

@ -7,4 +7,4 @@ actual class DriverFactory {
actual fun createDriver(): SqlDriver { actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(ReaderForSelfossDB.Schema, "ReaderForSelfossV2-IOS.db") return NativeSqliteDriver(ReaderForSelfossDB.Schema, "ReaderForSelfossV2-IOS.db")
} }
} }

View File

@ -3,4 +3,4 @@ package bou.amine.apps.readerforselfossv2.rest
import io.ktor.client.engine.cio.CIOEngineConfig import io.ktor.client.engine.cio.CIOEngineConfig
actual fun setupInsecureHTTPEngine(config: CIOEngineConfig) { actual fun setupInsecureHTTPEngine(config: CIOEngineConfig) {
} }

View File

@ -10,4 +10,4 @@ actual class DateUtils {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }
} }

View File

@ -22,6 +22,10 @@ actual fun SelfossModel.Source.getIcon(baseUrl: String): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
actual fun constructUrl(baseUrl: String, path: String, file: String?): String { actual fun constructUrl(
baseUrl: String,
path: String,
file: String?,
): String {
TODO("Not yet implemented") TODO("Not yet implemented")
} }

View File

@ -0,0 +1,9 @@
package bou.amine.apps.readerforselfossv2.utils
actual class DateUtils actual constructor() {
actual companion object {
actual fun parseRelativeDate(dateString: String): String {
TODO("Not yet implemented")
}
}
}

Some files were not shown because too many files have changed in this diff Show More