Compare commits

..

No commits in common. "master" and "m3-merger" have entirely different histories.

1193 changed files with 9377 additions and 39044 deletions

3
.github/FUNDING.yml vendored
View file

@ -1,11 +1,12 @@
# These are supported funding model platforms # These are supported funding model platforms
github: LucasGGamerM github: LucasGGamerM
custom: ["https://liberapay.com/LucasGGamerM/donate", liberapay.com]
patreon: # mastodon patreon: # mastodon
open_collective: # Replace with a single Open Collective username e.g., user1 open_collective: # Replace with a single Open Collective username e.g., user1
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: LucasGGamerM # Replace with a single Liberapay username e.g., user1 liberapay: # Replace with a single Liberapay username e.g., user1
issuehunt: # Replace with a single IssueHunt username e.g., user1 issuehunt: # Replace with a single IssueHunt username e.g., user1
otechie: # Replace with a single Otechie username e.g., user1 otechie: # Replace with a single Otechie username e.g., user1
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

@ -25,7 +25,7 @@ Does this issue also occur with the respective upstream release?
> No / Yes > No / Yes
> In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead. > In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead.
> If this bug is seriously impacting your usage or you think I might want to try to fix it for Moshidon, feel free to still create this issue! > If this bug is seriously impacting your usage or you think I might want to try to fix it for Megalodon, feel free to still create this issue!
**Screenshots and screen recordings** **Screenshots and screen recordings**

View file

@ -1,16 +0,0 @@
name: Mirror to Codeberg
on: [push]
jobs:
sync-git:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: yesolutions/mirror-action@master
with:
REMOTE: 'https://codeberg.org/LucasGGamerM/moshidon.git'
GIT_USERNAME: LucasGGamerM
GIT_PASSWORD: ${{ secrets.CODEBERG_GIT_PASSWORD }}

View file

@ -3,7 +3,6 @@ name: Nightly builds
on: on:
push: push:
branches: [ "master" ] branches: [ "master" ]
workflow_dispatch:
jobs: jobs:
build: build:
@ -11,27 +10,27 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# - name: Checkout Appkit Repo - name: Checkout Appkit Repo
# uses: actions/checkout@v3 uses: actions/checkout@v3
# with: with:
# repository: grishka/appkit repository: grishka/appkit
#
# - name: set up JDK 17 - name: set up JDK 17
# uses: actions/setup-java@v3 uses: actions/setup-java@v3
# with: with:
# java-version: '17' java-version: '17'
# distribution: 'corretto' distribution: 'corretto'
# cache: gradle cache: gradle
#
# - name: Comment out signing config in appkits gradle file - name: Comment out signing config in appkits gradle file
# run: | run: |
# sed -i 's/sign publishing\.publications\.release/\/\/ sign publishing.publications.release/' appkit/maven-push.gradle sed -i 's/sign publishing\.publications\.release/\/\/ sign publishing.publications.release/' appkit/maven-push.gradle
#
# - name: Grant execute permission for gradlew for Appkit - name: Grant execute permission for gradlew for Appkit
# run: chmod +x gradlew run: chmod +x gradlew
#
# - name: Compile appkit - name: Compile appkit
# run: ./gradlew publishToMavenLocal run: ./gradlew publishToMavenLocal
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: set up JDK 17 - name: set up JDK 17
@ -65,7 +64,7 @@ jobs:
CURRENT_DATE: ${{ steps.date.outputs.date }} CURRENT_DATE: ${{ steps.date.outputs.date }}
- name: Upload a Build Artifact - name: Upload a Build Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3.1.2
with: with:
name: moshidon-nightly.apk name: moshidon-nightly.apk
path: ./mastodon/build/outputs/apk/nightly/moshidon-nightly.apk path: ./mastodon/build/outputs/apk/nightly/moshidon-nightly.apk

56
FAQ.md
View file

@ -4,60 +4,6 @@ Q: What are the main differences between Moshidon and Megalodon?
A: There are many, but the most outstanding differences are: the ability to have other server's local timeline inside the app. It can be acessed in the "Add community" option in the top right corner of the Edit timelines screen. Other outstanding features that Moshidon has are some quality of life improvements, such as notification actions and allowing for unlisted replies by default. Most other features are pretty minor, such as profile notes directly available in the person's profile. Other features are quite minor usability and visibility improvements. All of which can be found in the settings page. A: There are many, but the most outstanding differences are: the ability to have other server's local timeline inside the app. It can be acessed in the "Add community" option in the top right corner of the Edit timelines screen. Other outstanding features that Moshidon has are some quality of life improvements, such as notification actions and allowing for unlisted replies by default. Most other features are pretty minor, such as profile notes directly available in the person's profile. Other features are quite minor usability and visibility improvements. All of which can be found in the settings page.
Q: Will there ever be a version of Moshidon for iOS? Q: Will there ever be a versjon of Moshidon for iOS?
A: No. As android and iOS apps do not share code, it is incredibly hard to port. A: No. As android and iOS apps do not share code, it is incredibly hard to port.
## Detailed changes
### Features
* [Adding the ability to view other server's local timelines](https://github.com/LucasGGamerM/moshidon/tree/feature/local-timelines)
* [Adding the ability to load followers and following from remote instance](https://github.com/LucasGGamerM/moshidon/tree/feature/remote-followers)
* [Adding the ability to have filtered posts show with a warning](https://github.com/LucasGGamerM/moshidon/tree/feature/filters_again)
* [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/enable-unlisted)
([Pull request](https://github.com/mastodon/mastodon-android/pull/103))
* Adding a useful private profile note box
* Auto hiding the compose button on scroll
* Adding the ability to remind yourself to add alt text to images
* An indicator for if an image has alt text or not
* Adding the ability to have drafts
* Also adding the ability to view announcements from your instance
* Adding the ability to post for local timeline only (Only on instances that support it!)
* [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
* [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
* [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21))
* [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22))
* [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/check-for-update-button)
* [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/mark-media-as-sensitive)
* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317))
* [Follow and unfollow hashtags](https://github.com/sk22/megalodon/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233))
* [Notification bell for posts](https://github.com/sk22/megalodon/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81))
* [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286)
* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/favs-list)
* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/follow-requests)
* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
* [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/posts-notifications-tab)
* [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-reply-visibility)
* [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:clickable-boost-reply-line)
* [Clickable reply line while replying to open original post](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/clickable-reply-line-compose)
### Behavior
* Ask for confirmation before reblogging
* Adding a bottom option for the publish button, allowing for easier use on larger screens!
* [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118))
* [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113))
* [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166))
* [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/hide-interaction-numbers)
* [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
* [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/disable-marquee)
### Visual
* [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:compact-extended-footer)
* [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:true-black-improvements)
* [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:ui/profile-header-tweaks)

233
README.md
View file

@ -1,91 +1,183 @@
# ![MoshidonLogo](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) Moshidon, the material you mastodon client! ![MoshidonLogo](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png)
# Moshidon, the material you mastodon client!
> A fork of [megalodon](https://github.com/sk22/megalodon) which is a fork of [official Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app and possibly wont ever be implemented, such as the federated timeline, unlisted posting, bookmarks and an image description viewer.
> A fast, highly customizable, up-to-date fork of [megalodon](https://github.com/sk22/megalodon) adding important features such as a fully federated timeline, unlisted posting, drafts, scheduled posts, bookmarks, and alt text warnings. [![Download latest release](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk)
[![Download nightly release](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20Nightly%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon-nightly/releases/latest/download/moshidon-nightly.apk)
## Download Now [![Translation status](https://translate.codeberg.org/widgets/moshidon/-/svg-badge.svg)](https://translate.codeberg.org/engage/moshidon/)
 
[![Nightly build](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml/badge.svg)](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml)
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android.moshinda"><img height="35" alt="Get it on Google Play" src="img/google-play-badge.png"></a> <a href="https://f-droid.org/pt_BR/packages/org.joinmastodon.android.moshinda"><img height="35" alt="Get it on F-Droid" src="img/f-droid-badge.png"></a> <a href="https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda"><img height="35" alt="Get it on IzzyOnDroid" src="img/izzy-badge.png"></a> <a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android.moshinda"><img height="50" alt="Get it on Google Play" src="img/google-play-badge.png"></a>
&nbsp;
<a href="https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda"><img height="50" alt="Get it on IzzyOnDroid" src="img/izzy-badge.png"></a>
[![GitHub Release Download](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) [![Translation status](https://translate.codeberg.org/widgets/moshidon/-/svg-badge.svg)](https://translate.codeberg.org/engage/moshidon/) [![GitHub Nightly Download](https://img.shields.io/badge/dynamic/json?color=282C37&label=Download%20Nightly%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FLucasGGamerM%2Fmoshidon%2Freleases%2Flatest&style=for-the-badge)](https://github.com/LucasGGamerM/moshidon-nightly/releases/latest/download/moshidon-nightly.apk) [![GitHub Nightly Build Download](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml/badge.svg)](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml) ## Help out the project by donating at: https://github.com/sponsors/LucasGGamerM!
### We also support LiberaPay at: https://liberapay.com/LucasGGamerM/donate (Currently broken)
## Donate ### You can also donate some Monero through this wallet address as well:
4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j
<a href="https://github.com/sponsors/LucasGGamerM">Github Sponsors</a> | <a href="https://liberapay.com/LucasGGamerM/donate">Liberapay</a> | Monero Wallet Key: `4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j` ---
## Key Features ## Key features
[ screenshot of full timeline in default colour scheme ] ### **The ability to add other server's local timeline to your timelines**
[ screenshot of full timeline in an alt colour scheme ]
[ screenshot of profile page ]
[ screenshot of compose post window ]
### Flexible Timelines It can be accessed in the "Edit timelines" menu, where you can add a new "Community" to see other server's local posts!
[ Home dropdown menu ] ### **View remote profiles**
Under the Home menu by default you can see your active account's timeline, your server's local timeline, and your server's federated timeline. You can also pin hashtags, lists, other servers, or make a custom view of just your posts, your bookmarks, or your favourites for quick access. Then sort these timelines to prioritize the ones you visit most often. You can now see all of a profile follows and followers, by directly loading them from the profile's home instance. In case of a failed lookup, the app will automatically fall back to the older method.
### Multiple Accounts & Crossposting ### **Translate posts easily**
Sign in to multiple accounts in the same app and easily switch between them. Press and hold on the boost or fave button to boost or fave a post to a different account than the one you are currently browsing with. Allows you to easily translate posts in another language with a translate button! Your instance must support translation, otherwise it will not work.
[ boost icon pop up select profile ] ### **Show posts filtered with a warning**
### Drafts & Scheduled Posts Allows you to have filtered posts collapsed with a warning! As shown in the screenshots:
Write posts and save them, or schedule them to post later. Edit and delete your drafts. Before | After
:-------------------------:|:-------------------------:
### Alt Text Tag & Reminder ![Screenshot_20230205-100200edited](https://user-images.githubusercontent.com/71328265/216820539-20802dc5-e433-4511-b2d9-291d810e4ef2.png) | ![Screenshot_20230205-100203edited](https://user-images.githubusercontent.com/71328265/216820544-231b2966-f38f-4ec6-b555-d39c62433839.png)
An unobtrusive ALT tag appears on images with alt text. Clicking on the icon makes the alt text appear. By default, Moshidon will show a warning to add alt text if your post has any attachments lacking alt text. This is for better accessibility, and it can be disabled in settings. You can also hide from your feed all posts that are lacking in alt text.
[ image with alt text icon higlighted ]
[ alt text expanded ]
### Themes & Customization
Moshidon is designed according to Material Design principles. Follow your device's light or dark mode settings or change colour palette - your system's default, purple, black & white, "pitch black" (battery saving) and more. Customize your experience by moving or renaming the publish button, show or hide sensitive media by default, reduce motion, collapse long posts, add haptic feedback, or making the fave button a heart &hearts; or a star &starf;.
### Not Just For Mastodon
Supports features available on other types of fediverse servers such as admin announcements, showing pronouns in user names, post translation, emoji reactions, local-only posting, and markdown or html in posts.
### Fully Federated Feed & Profiles
See all public posts from servers your server federates with and fetch profiles from a user's local server for accurate up to date information.
## And more...
- quote-posts - links to fediverse posts in other posts will be loaded inline like quote-tweets
- manage pinned posts and bookmarks
- manage lists, filters, and most privacy settings
- display pronouns in timelines, threads, and user listings
- get only specific types of notifications (no more finished polls!), limit who you get notifications from, or group all notifications into one.
- automatically add "re:" to beginning of replies with content warnings
- ask before boosting or deleting posts
- when replying to a boosted post automatically mention the person who boosted it
- overlay audio from posts, allowing your existing media to keep playing
- auto-reveal CWs that are the same as ones you've already opened, or always reveal content warnings and sensitive media
- hide media previews in timelines (save data)
- show post interaction counts in timeline
- allow custom emoji in display names
- enable scrolling text for long display names
- hide interaction buttons
- show post dividers
## Installation & Releases ### **Color themes**
Moshidon is available on GitHub, Google Play, F-Droid, and the IzzyOnDroid repo. All sources provide the same ` moshidon.apk ` stable release. Older releases are available on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page. Allows you to change theme within the app. Supports Material You, purple, pink, green, blue, red, orange, yellow and Nord!
### How to Install from GitHub ### **Unlisted posting**
[Download the latest stable release from Github](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) and open it. You might have to accept installing APK files from your browser. Moshidon will automatically check for new updates available on GitHub and offer to download and install them within the app. You can also manually press “Check for updates” at the bottom of the settings page.
### Nightly Version **Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Local”, “Community” and “Posts”).**
All ` moshidon-night.apk ` nightly builds can be downloaded on the [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page. This is an unstable version with an integrated updater for development and testing purposes. If you find any bugs with it, please file a bug report on our [Issues](https://github.com/LucasGGamerM/moshidon/issues) page.
## Building & Contributing When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in peoples Home timelines, but only if they follow you or someone they follow reposted/replied to your post.
The Mastodon documentation has some more information about [Unlisted posting](https://docs.joinmastodon.org/user/posting/#unlisted) and [Public timelines](https://docs.joinmastodon.org/user/network/#timelines).
### **Federated timeline**
**This allows you to chronologically see all Public posts from people on all other Fediverse neighborhoods your home instance is connected to.**
Despite being one of the main features of federated social media, the Federated timeline wasnt included in the official Mastodon app supposedly, because this conflicts with Googles safety requirements for apps on the Play Store.
Thats one of the reasons why choosing a small, **well-moderated instance is important**. Instance admins and moderators should always make sure to ban abusive users and stop federating with instances who platform them. On well-moderated instances, the Federated timeline can be a welcoming place to meet new people!
### **Image description viewer**
**Allows you to quickly check whether an image or video has an alternative text attached to it.**
This is important to **ensure the content youre sharing is as accessible as possible** to people who cant see the images and rely on software to read back the provided content descriptions. Thankfully, its quite common for people on the Fediverse to provide such alt texts, and hopefully things stay this way!
### **Reminder to add alt text to attached media**
By default, Moshidon will show a warning to add alt text if your post has any attachments without any alt text. This is for better accessibility, and it can easily be bypassed and disabled in settings.
### **Pinning posts**
**This lets you can highlight important posts on your profile. A dedicated “Pinned” tab in peoples profiles shows all the posts they pinned.**
On the Fediverse, its quite common for people to pin posts they want others to read before following them. You can pin/unpin posts yourself by clicking the `⋯` button in the top right corner of your posts.
### **Bookmarks**
**They allow for quickly saving posts and viewing them through the Bookmarks button on the top right of your profile.**
To bookmark a post, press the button between the Favorite and Share buttons on the bottom of the post. Bookmarks are saved privately, so the post authors wont know you saved their post the list of bookmarked posts is only visible to you.
## Installation
**Press the download button above to download the APK. Open the downloaded file on your Android device to install it. Moshidon will automatically notify you about new updates inside the app.**
To install this app on your Android device, download the [latest release from GitHub](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) and open it. You might have to accept installing APK files from your browser when trying to install it. You can also take a look at all releases on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page.
Moshidon makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)s automatic update checker. Moshidon will check for new updates available on GitHub and offer to download and install them. You can also manually press “Check for updates” at the bottom of the settings page!
Moshidon is also available in [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda), compatible with all F-Droid clients. The APK provided here is the same as the one included in the Releases.
## Release variants
### Stable variant
All stable version downloads can be found on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page.
**`moshidon.apk`**
Variant with an integrated updater. If you download Moshidon from here (and not from an app store), just download the regular `moshidon.apk`.
### Nightly variant
All nightly builds can be downloaded at [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page.
**`moshidon-nightly.apk`**
Unstable variant with an integrated updater. It's for development and testing purposes. If you find any bugs with it, please file a bug report at our [issues](https://github.com/LucasGGamerM/moshidon/issues) page.
---
## Detailed changes
### Features
* [Adding the ability to view other server's local timelines](https://github.com/LucasGGamerM/moshidon/tree/feature/local-timelines)
* [Adding the ability to load followers and following from remote instance](https://github.com/LucasGGamerM/moshidon/tree/feature/remote-followers)
* [Adding the ability to have filtered posts show with a warning](https://github.com/LucasGGamerM/moshidon/tree/feature/filters_again)
* [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/enable-unlisted)
([Pull request](https://github.com/mastodon/mastodon-android/pull/103))
* Adding a useful private profile note box
* Auto hiding the compose button on scroll
* Adding the ability to remind yourself to add alt text to images
* An indicator for if an image has alt text or not
* Adding the ability to have drafts
* Also adding the ability to view announcements from your instance
* Adding the ability to post for local timeline only (Only on instances that support it!)
* [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
* [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
* [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21))
* [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22))
* [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/check-for-update-button)
* [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/mark-media-as-sensitive)
* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317))
* [Follow and unfollow hashtags](https://github.com/sk22/megalodon/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233))
* [Notification bell for posts](https://github.com/sk22/megalodon/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81))
* [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286)
* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/favs-list)
* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/follow-requests)
* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
* [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/posts-notifications-tab)
* [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-reply-visibility)
* [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:clickable-boost-reply-line)
* [Clickable reply line while replying to open original post](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/clickable-reply-line-compose)
### Behavior
* Allow for confirmation before reblogging
* Adding a bottom option for the publish button, allowing for easier use on larger screens!
* [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118))
* [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113))
* [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166))
* [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/hide-interaction-numbers)
* [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
* [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/disable-marquee)
### Visual
* [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:compact-extended-footer)
* [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:true-black-improvements)
* [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:ui/profile-header-tweaks)
## Building
As this app is using Java 17 features, you need JDK 17 or newer to build it. Other than that, everything is pretty standard. You can either import the project into Android Studio and build it from there, or run the following command in the project directory: As this app is using Java 17 features, you need JDK 17 or newer to build it. Other than that, everything is pretty standard. You can either import the project into Android Studio and build it from there, or run the following command in the project directory:
@ -97,13 +189,14 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth
This project is released under the [GPL-3 License](./LICENSE). This project is released under the [GPL-3 License](./LICENSE).
## Contact & Support ## Links
**<a rel="me" href="https://floss.social/@moshidon">@moshidon@floss.social</a>**
[Official Matrix Chatroom](https://matrix.to/#/#moshidon:floss.social)
[F.A.Q](FAQ.md) [F.A.Q](FAQ.md)
[Moshidon Roadmap](https://github.com/users/LucasGGamerM/projects/1) [Official matrix chatroom:](https://matrix.to/#/#moshidon:floss.social) https://matrix.to/#/#moshidon:floss.social
[Moshidon roadmap](https://github.com/users/LucasGGamerM/projects/1)
<a rel="me" href="https://floss.social/@moshidon">@moshidon<wbr>@floss.social</a>
---

View file

@ -1,3 +1,23 @@
plugins { // Top-level build file where you can add configuration options common to all sub-projects/modules.
id("com.android.application") version "8.7.2" apply false buildscript {
repositories {
google()
mavenCentral()
maven {
url "https://www.jitpack.io"
content {
includeModule 'com.github.UnifiedPush', 'android-connector'
}
}
mavenLocal()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
task clean(type: Delete) {
delete rootProject.buildDir
} }

0
fix-metadata-markdown-lists.sh Normal file → Executable file
View file

View file

@ -17,5 +17,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX # Automatically convert third-party libraries to use AndroidX
android.enableJetifier=false android.enableJetifier=false
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=false android.nonFinalResIds=false
org.gradle.configuration-cache=true

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=57dafb5c2622c6cc08b993c85b7c06956a2f53536432a30ead46166dbca0f1e9 distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View file

@ -15,20 +15,13 @@ android {
archivesBaseName = "moshidon" archivesBaseName = "moshidon"
applicationId "org.joinmastodon.android.moshinda" applicationId "org.joinmastodon.android.moshinda"
minSdk 23 minSdk 23
targetSdk 34 targetSdk 33
versionCode 108 versionCode 101
versionName "2.3.0+fork.108.moshinda.luke" versionName "1.2.3+fork.101.moshinda"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW'] resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW']
} }
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
signingConfigs { signingConfigs {
nightly{ nightly{
storeFile = file("keystore/nightly_keystore.jks") storeFile = file("keystore/nightly_keystore.jks")
@ -51,28 +44,6 @@ android {
keyPassword = properties.getProperty('SIGNING_KEY_PASSWORD') keyPassword = properties.getProperty('SIGNING_KEY_PASSWORD')
} }
} }
// release{
// storeFile = file("keystore/release_keystore.jks")
// storePassword System.getenv("RELEASE_SIGNING_STORE_PASSWORD")
// if (storePassword == null) {
// Properties properties = new Properties()
// properties.load(project.rootProject.file('local.properties').newDataInputStream())
// storePassword = properties.getProperty('RELEASE_SIGNING_STORE_PASSWORD')
// }
// keyAlias System.getenv("RELEASE_SIGNING_KEY_ALIAS")
// if (keyAlias == null) {
// Properties properties = new Properties()
// properties.load(project.rootProject.file('local.properties').newDataInputStream())
// keyAlias = properties.getProperty('RELEASE_SIGNING_KEY_ALIAS')
// }
// keyPassword System.getenv("RELEASE_SIGNING_KEY_PASSWORD")
// if (keyPassword == null) {
// Properties properties = new Properties()
// properties.load(project.rootProject.file('local.properties').newDataInputStream())
// keyPassword = properties.getProperty('RELEASE_SIGNING_KEY_PASSWORD')
// }
// }
} }
buildTypes { buildTypes {
@ -109,17 +80,9 @@ android {
shrinkResources true shrinkResources true
versionNameSuffix '-play' versionNameSuffix '-play'
} }
githubRelease { githubRelease { initWith release }
initWith release playRelease { initWith release }
versionNameSuffix '-github' fdroidRelease { initWith release }
}
fdroidRelease {
initWith release
vcsInfo.include false
// The F-droid build system doesn't like this at all for some reason.
// versionNameSuffix '-fdroid'
// signingConfig signingConfigs.release
}
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
@ -155,7 +118,7 @@ dependencies {
implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.litex:palette:1.0.0' implementation 'me.grishka.litex:palette:1.0.0'
implementation 'me.grishka.appkit:appkit:1.2.16' implementation 'me.grishka.appkit:appkit:1.2.9'
implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.google.code.gson:gson:2.9.0'
implementation 'org.jsoup:jsoup:1.14.3' implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8' implementation 'com.squareup:otto:1.3.8'
@ -164,7 +127,7 @@ dependencies {
implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0'
annotationProcessor 'org.parceler:parceler:1.1.12' annotationProcessor 'org.parceler:parceler:1.1.12'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
implementation 'org.unifiedpush.android:connector:3.0.7' implementation 'com.github.UnifiedPush:android-connector:2.1.1'
androidTestImplementation 'androidx.test:core:1.5.0' androidTestImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'

View file

@ -20,8 +20,6 @@
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-dontwarn android.app.BroadcastOptions
# Keep all model classes as they're used with gson and their names are shown in errors # Keep all model classes as they're used with gson and their names are shown in errors
-keep public class org.joinmastodon.android.model.**{ -keep public class org.joinmastodon.android.model.**{
<fields>; <fields>;

View file

@ -257,9 +257,5 @@ public class UiUtilsTest {
assertEquals("* (asterisk)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount( assertEquals("* (asterisk)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount(
makeField("pronouns", "-- * (asterisk) --") makeField("pronouns", "-- * (asterisk) --")
)).orElseThrow()); )).orElseThrow());
assertEquals("they/(she?)", UiUtils.extractPronouns(MastodonApp.context, fakeAccount(
makeField("pronouns", "they/(she?)...")
)).orElseThrow());
} }
} }

View file

@ -0,0 +1,81 @@
package org.joinmastodon.android.utils;
import static org.joinmastodon.android.model.FilterAction.*;
import static org.joinmastodon.android.model.FilterContext.*;
import static org.junit.Assert.*;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import org.junit.Test;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
public class StatusFilterPredicateTest {
private static final LegacyFilter hideMeFilter = new LegacyFilter(), warnMeFilter = new LegacyFilter();
private static final List<LegacyFilter> allFilters = List.of(hideMeFilter, warnMeFilter);
private static final Status
hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()),
warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now());
static {
hideMeFilter.phrase = "hide me";
hideMeFilter.filterAction = HIDE;
hideMeFilter.context = EnumSet.of(PUBLIC, HOME);
warnMeFilter.phrase = "warning";
warnMeFilter.filterAction = WARN;
warnMeFilter.context = EnumSet.of(PUBLIC, HOME);
}
@Test
public void testHide() {
assertFalse("should not pass because matching filter applies to given context",
new StatusFilterPredicate(allFilters, HOME).test(hideInHomePublic));
}
@Test
public void testHideRegardlessOfContext() {
assertTrue("filters without context should always pass",
new StatusFilterPredicate(allFilters, null).test(hideInHomePublic));
}
@Test
public void testHideInDifferentContext() {
assertTrue("should pass because matching filter does not apply to given context",
new StatusFilterPredicate(allFilters, THREAD).test(hideInHomePublic));
}
@Test
public void testHideWithWarningText() {
assertTrue("should pass because matching filter is for warnings",
new StatusFilterPredicate(allFilters, HOME).test(warnInHomePublic));
}
@Test
public void testWarn() {
assertFalse("should not pass because filter applies to given context",
new StatusFilterPredicate(allFilters, HOME, WARN).test(warnInHomePublic));
}
@Test
public void testWarnRegardlessOfContext() {
assertTrue("filters without context should always pass",
new StatusFilterPredicate(allFilters, null, WARN).test(warnInHomePublic));
}
@Test
public void testWarnInDifferentContext() {
assertTrue("should pass because filter does not apply to given context",
new StatusFilterPredicate(allFilters, THREAD, WARN).test(warnInHomePublic));
}
@Test
public void testWarnWithHideText() {
assertTrue("should pass because matching filter is for hiding",
new StatusFilterPredicate(allFilters, HOME, WARN).test(hideInHomePublic));
}
}

View file

@ -211,13 +211,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
if(state==UpdateState.DOWNLOADING) if(state==UpdateState.DOWNLOADING)
throw new IllegalStateException(); throw new IllegalStateException();
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED);
}else{
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
}
downloadID=dm.enqueue( downloadID=dm.enqueue(
new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null))) new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null)))
.setDestinationUri(Uri.fromFile(getUpdateApkFile())) .setDestinationUri(Uri.fromFile(getUpdateApkFile()))

View file

@ -5,8 +5,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/> <uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
@ -32,7 +31,6 @@
android:name=".MastodonApp" android:name=".MastodonApp"
android:allowBackup="true" android:allowBackup="true"
android:label="@string/mo_app_name" android:label="@string/mo_app_name"
android:dataExtractionRules="@xml/backup_rules"
android:supportsRtl="true" android:supportsRtl="true"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -82,15 +80,6 @@
<data android:mimeType="*/*"/> <data android:mimeType="*/*"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".ChooseAccountForComposeActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize"
android:theme="@style/TransparentDialog">
<intent-filter>
<action android:name="android.intent.action.CHOOSER"/>
<category android:name="android.intent.category.LAUNCHER"/>
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/> <service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
@ -116,11 +105,13 @@
</receiver> </receiver>
<provider <provider
android:name="org.joinmastodon.android.utils.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
android:name=".TweakedFileProvider" android:exported="false"
android:grantUriPermissions="true" android:grantUriPermissions="true">
android:exported="false"> <meta-data
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/fileprovider_paths"/> android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider> </provider>
</application> </application>

View file

@ -1,48 +1,56 @@
13bells.com 13bells.com
1611.social 1611.social
4aem.com 4aem.com
5dollah.click
adachi.party adachi.party
adtension.com anime.website
annihilation.social annihilation.social
anon-kenkai.com anon-kenkai.com
asbestos.cafe asbestos.cafe
bae.st bae.st
bajax.us
banepo.st banepo.st
baraag.net
bassam.social bassam.social
battlepenguin.video
beefyboys.win beefyboys.win
beepboop.ga
berserker.town
bikeshed.party
boks.moe
boymoder.biz boymoder.biz
brainsoap.net brainsoap.net
breastmilk.club breastmilk.club
brighteon.social brighteon.social
cachapa.xyz bungle.online
canary.fedinuke.example.com
catgirl.life
cawfee.club cawfee.club
childlove.su
clew.lol clew.lol
clubcyberia.co clubcyberia.co
collapsitarian.io
comfyboy.club
contrapointsfan.club contrapointsfan.club
cottoncandy.cafe
crlf.ninja
crucible.world
cum.camp cum.camp
cum.salon cum.salon
cunnyborea.space darknight-coffee.org
decayable.ink decayable.ink
dembased.xyz dembased.xyz
desupost.soy
detroitriotcity.com detroitriotcity.com
djsumdog.com eatthebugs.social
eientei.org eientei.org
elementality.org
eveningzoo.club eveningzoo.club
firedragonstudios.com
firefaithfellowship.com
fluf.club fluf.club
foxgirl.lol foxfam.club
freak.university freak.university
freeatlantis.com freeatlantis.com
freedomstrike.org
freesoftwareextremist.com
freespeech.group
freespeechextremist.com freespeechextremist.com
freetalklive.com
froth.zone froth.zone
fsebugoutzone.org fulltermprivacy.com
gameliberty.club gameliberty.club
gearlandia.haus gearlandia.haus
genderheretics.xyz genderheretics.xyz
@ -51,58 +59,62 @@ gleasonator.com
glee.li glee.li
glindr.org glindr.org
goyim.app goyim.app
h5q.net goyslop.cafe
haeder.net haeder.net
handholding.io handholding.io
harpy.faith
hitchhiker.social hitchhiker.social
hunk.city
iddqd.social iddqd.social
intkos.link
justicewarrior.social
kawa-kun.com
kitsunemimi.club kitsunemimi.club
kiwifarms.cc kiwifarms.cc
kompost.cz
kurosawa.moe kurosawa.moe
kyaruc.moe
leafposter.club leafposter.club
leftychan.net
lewdieheaven.com
liberdon.com liberdon.com
ligma.pro ligma.pro
loli.church
lolicon.rocks lolicon.rocks
lolison.network
lolison.top lolison.top
lovingexpressions.net lovingexpressions.net
mahodou.moe
makemysarcophagus.com makemysarcophagus.com
maladaptive.art
marsey.moe
masochi.st
mastinator.com mastinator.com
merovingian.club merovingian.club
midwaytrades.com midwaytrades.com
mirr0r.city mirr0r.city
morale.ch moa.st
mouse.services mouse.services
mugicha.club mugicha.club
narrativerry.xyz narrativerry.xyz
natehiggers.online natehiggers.online
nationalist.social neckbeard.xyz
needs.vodka needs.vodka
neenster.org neenster.org
nicecrew.digital nicecrew.digital
nightshift.social
nnia.space nnia.space
noagendasocial.com noagendasocial.com
noagendasocial.nl noagendasocial.nl
noagendatube.com noagendatube.com
noauthority.social
nobodyhasthe.biz nobodyhasthe.biz
norwoodzero.net nukem.biz
nyanide.com obo.sh
onionfarms.org onionfarms.org
parcero.bond
pawlicker.com pawlicker.com
pawoo.net pawoo.net
pedo.school pedo.school
peervideo.club
piazza.today piazza.today
pibvt.net pibvt.net
pieville.net pieville.net
pisskey.io pisskey.io
plagu.ee plagu.ee
pmth.us
poa.st poa.st
poast.org poast.org
poast.tv poast.tv
@ -111,18 +123,17 @@ prospeech.space
quodverum.com quodverum.com
r18.social r18.social
rakket.app rakket.app
rapemeat.express
rapemeat.solutions rapemeat.solutions
rayci.st rdrama.cc
rebelbase.site rebelbase.site
retardedniggers.forsale
rojogato.com
ryona.agency ryona.agency
sad.cab
schwartzwelt.xyz schwartzwelt.xyz
seal.cafe seal.cafe
shaw.app
shigusegubu.club shigusegubu.club
shitpost.cloud shitpost.cloud
shortstacksran.ch shota.house
silliness.observer silliness.observer
skinheads.eu skinheads.eu
skinheads.io skinheads.io
@ -137,24 +148,23 @@ sneed.social
sonichu.com sonichu.com
spinster.xyz spinster.xyz
springbo.cc springbo.cc
starnix.network
strelizia.net strelizia.net
taihou.website syspxl.xyz
tastingtraffic.net tastingtraffic.net
teci.world teci.world
theapex.social theapex.social
theblab.org
thechimp.zone
thenobody.club
thepostearthdestination.com thepostearthdestination.com
tkammer.de tkammer.de
trumpislovetrumpis.life trumpislovetrumpis.life
truthsocial.co.in truthsocial.co.in
usualsuspects.lol urchan.org
vampiremaid.cafe
varishangout.net varishangout.net
vtuberfan.social whinge.house
whinge.town
wideboys.org
wolfgirl.bar wolfgirl.bar
xn--p1abe3d.xn--80asehdb xn--p1abe3d.xn--80asehdb
yggdrasil.social yggdrasil.social
youjo.love youjo.love
zhub.link zztails.gay

View file

@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -88,13 +88,8 @@ public class AudioPlayerService extends Service{
nm=getSystemService(NotificationManager.class); nm=getSystemService(NotificationManager.class);
// registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON)); // registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON));
registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){ registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE), RECEIVER_EXPORTED); registerReceiver(receiver, new IntentFilter(ACTION_STOP));
registerReceiver(receiver, new IntentFilter(ACTION_STOP), RECEIVER_EXPORTED);
}else{
registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
registerReceiver(receiver, new IntentFilter(ACTION_STOP));
}
instance=this; instance=this;
} }
@ -271,7 +266,7 @@ public class AudioPlayerService extends Service{
private void updateNotification(boolean dismissable, boolean removeNotification){ private void updateNotification(boolean dismissable, boolean removeNotification){
Notification.Builder bldr=new Notification.Builder(this) Notification.Builder bldr=new Notification.Builder(this)
.setSmallIcon(R.drawable.ic_ntf_logo) .setSmallIcon(R.drawable.ic_ntf_logo)
.setContentTitle(status.account.getDisplayName()) .setContentTitle(status.account.displayName)
.setContentText(HtmlParser.strip(status.content)) .setContentText(HtmlParser.strip(status.content))
.setOngoing(!dismissable) .setOngoing(!dismissable)
.setShowWhen(false) .setShowWhen(false)
@ -286,7 +281,7 @@ public class AudioPlayerService extends Service{
if(playerReady){ if(playerReady){
boolean isPlaying=player.isPlaying(); boolean isPlaying=player.isPlaying();
bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_fluent_pause_24_filled : R.drawable.ic_fluent_play_24_filled), bldr.addAction(new Notification.Action.Builder(Icon.createWithResource(this, isPlaying ? R.drawable.ic_pause_24 : R.drawable.ic_play_24),
getString(isPlaying ? R.string.pause : R.string.play), getString(isPlaying ? R.string.pause : R.string.play),
PendingIntent.getBroadcast(this, 2, new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_IMMUTABLE)) PendingIntent.getBroadcast(this, 2, new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_IMMUTABLE))
.build()); .build());

View file

@ -1,52 +0,0 @@
package org.joinmastodon.android;
import android.app.Fragment;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Toast;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import java.util.Objects;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
public class ChooseAccountForComposeActivity extends FragmentStackActivity{
@Override
protected void onCreate(@Nullable Bundle savedInstanceState){
UiUtils.setUserPreferredTheme(this);
super.onCreate(savedInstanceState);
if (savedInstanceState == null && Objects.equals(getIntent().getAction(), Intent.ACTION_CHOOSER)) {
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts();
if (sessions.isEmpty()){
Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show();
finish();
} else if (sessions.size() > 1) {
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_compose_28_regular,
R.string.choose_account, null, false);
sheet.setOnClick((accountId, open) -> {
openComposeFragment(accountId);
});
sheet.show();
} else if (sessions.size() == 1) {
openComposeFragment(sessions.get(0).getID());
}
}
}
private void openComposeFragment(String accountID){
getWindow().setBackgroundDrawable(null);
Bundle args=new Bundle();
args.putString("account", accountID);
Fragment fragment=new ComposeFragment();
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
}
}

View file

@ -13,7 +13,7 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.jsoup.internal.StringUtil; import org.jsoup.internal.StringUtil;
@ -32,9 +32,10 @@ public class ExternalShareActivity extends FragmentStackActivity{
UiUtils.setUserPreferredTheme(this); UiUtils.setUserPreferredTheme(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
if(savedInstanceState==null){ if(savedInstanceState==null){
Optional<String> text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT)); Optional<String> text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT));
Optional<Pair<String, Optional<String>>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle); Optional<Pair<String, Optional<String>>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle);
boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false); boolean isFediUrl = text.map(UiUtils::looksLikeMastodonUrl).orElse(false);
boolean isOpenable = isFediUrl || fediHandle.isPresent(); boolean isOpenable = isFediUrl || fediHandle.isPresent();
List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts();
@ -42,11 +43,7 @@ public class ExternalShareActivity extends FragmentStackActivity{
Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show();
finish(); finish();
} else if (isOpenable || sessions.size() > 1) { } else if (isOpenable || sessions.size() > 1) {
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_share_28_regular, AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable);
isOpenable
? R.string.sk_external_share_or_open_title
: R.string.sk_external_share_title,
null, isOpenable);
sheet.setOnClick((accountId, open) -> { sheet.setOnClick((accountId, open) -> {
if (open && text.isPresent()) { if (open && text.isPresent()) {
BiConsumer<Class<? extends Fragment>, Bundle> callback = (clazz, args) -> { BiConsumer<Class<? extends Fragment>, Bundle> callback = (clazz, args) -> {
@ -86,8 +83,6 @@ public class ExternalShareActivity extends FragmentStackActivity{
} }
private void openComposeFragment(String accountID){ private void openComposeFragment(String accountID){
AccountSession session=AccountSessionManager.get(accountID);
UiUtils.setUserPreferredTheme(this, session);
getWindow().setBackgroundDrawable(null); getWindow().setBackgroundDrawable(null);
Intent intent=getIntent(); Intent intent=getIntent();

View file

@ -1,841 +0,0 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.joinmastodon.android;
import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
import static org.xmlpull.v1.XmlPullParser.START_TAG;
import android.content.ClipData;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.content.res.XmlResourceParser;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.xmlpull.v1.XmlPullParserException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* FileProvider is a special subclass of {@link ContentProvider} that facilitates secure sharing
* of files associated with an app by creating a <code>content://</code> {@link Uri} for a file
* instead of a <code>file:///</code> {@link Uri}.
* <p>
* A content URI allows you to grant read and write access using
* temporary access permissions. When you create an {@link Intent} containing
* a content URI, in order to send the content URI
* to a client app, you can also call {@link Intent#setFlags(int) Intent.setFlags()} to add
* permissions. These permissions are available to the client app for as long as the stack for
* a receiving {@link android.app.Activity} is active. For an {@link Intent} going to a
* {@link android.app.Service}, the permissions are available as long as the
* {@link android.app.Service} is running.
* <p>
* In comparison, to control access to a <code>file:///</code> {@link Uri} you have to modify the
* file system permissions of the underlying file. The permissions you provide become available to
* <em>any</em> app, and remain in effect until you change them. This level of access is
* fundamentally insecure.
* <p>
* The increased level of file access security offered by a content URI
* makes FileProvider a key part of Android's security infrastructure.
* <p>
* This overview of FileProvider includes the following topics:
* </p>
* <ol>
* <li><a href="#ProviderDefinition">Defining a FileProvider</a></li>
* <li><a href="#SpecifyFiles">Specifying Available Files</a></li>
* <li><a href="#GetUri">Retrieving the Content URI for a File</li>
* <li><a href="#Permissions">Granting Temporary Permissions to a URI</a></li>
* <li><a href="#ServeUri">Serving a Content URI to Another App</a></li>
* </ol>
* <h3 id="ProviderDefinition">Defining a FileProvider</h3>
* <p>
* Since the default functionality of FileProvider includes content URI generation for files, you
* don't need to define a subclass in code. Instead, you can include a FileProvider in your app
* by specifying it entirely in XML. To specify the FileProvider component itself, add a
* <code><a href="{@docRoot}guide/topics/manifest/provider-element.html">&lt;provider&gt;</a></code>
* element to your app manifest. Set the <code>android:name</code> attribute to
* <code>androidx.core.content.FileProvider</code>. Set the <code>android:authorities</code>
* attribute to a URI authority based on a domain you control; for example, if you control the
* domain <code>mydomain.com</code> you should use the authority
* <code>com.mydomain.fileprovider</code>. Set the <code>android:exported</code> attribute to
* <code>false</code>; the FileProvider does not need to be public. Set the
* <a href="{@docRoot}guide/topics/manifest/provider-element.html#gprmsn"
* >android:grantUriPermissions</a> attribute to <code>true</code>, to allow you
* to grant temporary access to files. For example:
* <pre class="prettyprint">
*&lt;manifest&gt;
* ...
* &lt;application&gt;
* ...
* &lt;provider
* android:name="androidx.core.content.FileProvider"
* android:authorities="com.mydomain.fileprovider"
* android:exported="false"
* android:grantUriPermissions="true"&gt;
* ...
* &lt;/provider&gt;
* ...
* &lt;/application&gt;
*&lt;/manifest&gt;</pre>
* <p>
* If you want to override any of the default behavior of FileProvider methods, extend
* the FileProvider class and use the fully-qualified class name in the <code>android:name</code>
* attribute of the <code>&lt;provider&gt;</code> element.
* <h3 id="SpecifyFiles">Specifying Available Files</h3>
* A FileProvider can only generate a content URI for files in directories that you specify
* beforehand. To specify a directory, specify the its storage area and path in XML, using child
* elements of the <code>&lt;paths&gt;</code> element.
* For example, the following <code>paths</code> element tells FileProvider that you intend to
* request content URIs for the <code>images/</code> subdirectory of your private file area.
* <pre class="prettyprint">
*&lt;paths xmlns:android="http://schemas.android.com/apk/res/android"&gt;
* &lt;files-path name="my_images" path="images/"/&gt;
* ...
*&lt;/paths&gt;
*</pre>
* <p>
* The <code>&lt;paths&gt;</code> element must contain one or more of the following child elements:
* </p>
* <dl>
* <dt>
* <pre class="prettyprint">
*&lt;files-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* </dt>
* <dd>
* Represents files in the <code>files/</code> subdirectory of your app's internal storage
* area. This subdirectory is the same as the value returned by {@link Context#getFilesDir()
* Context.getFilesDir()}.
* </dd>
* <dt>
* <pre>
*&lt;cache-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* <dt>
* <dd>
* Represents files in the cache subdirectory of your app's internal storage area. The root path
* of this subdirectory is the same as the value returned by {@link Context#getCacheDir()
* getCacheDir()}.
* </dd>
* <dt>
* <pre class="prettyprint">
*&lt;external-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* </dt>
* <dd>
* Represents files in the root of the external storage area. The root path of this subdirectory
* is the same as the value returned by
* {@link Environment#getExternalStorageDirectory() Environment.getExternalStorageDirectory()}.
* </dd>
* <dt>
* <pre class="prettyprint">
*&lt;external-files-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* </dt>
* <dd>
* Represents files in the root of your app's external storage area. The root path of this
* subdirectory is the same as the value returned by
* {@code Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)}.
* </dd>
* <dt>
* <pre class="prettyprint">
*&lt;external-cache-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* </dt>
* <dd>
* Represents files in the root of your app's external cache area. The root path of this
* subdirectory is the same as the value returned by
* {@link Context#getExternalCacheDir() Context.getExternalCacheDir()}.
* </dd>
* <dt>
* <pre class="prettyprint">
*&lt;external-media-path name="<i>name</i>" path="<i>path</i>" /&gt;
*</pre>
* </dt>
* <dd>
* Represents files in the root of your app's external media area. The root path of this
* subdirectory is the same as the value returned by the first result of
* {@link Context#getExternalMediaDirs() Context.getExternalMediaDirs()}.
* <p><strong>Note:</strong> this directory is only available on API 21+ devices.</p>
* </dd>
* </dl>
* <p>
* These child elements all use the same attributes:
* </p>
* <dl>
* <dt>
* <code>name="<i>name</i>"</code>
* </dt>
* <dd>
* A URI path segment. To enforce security, this value hides the name of the subdirectory
* you're sharing. The subdirectory name for this value is contained in the
* <code>path</code> attribute.
* </dd>
* <dt>
* <code>path="<i>path</i>"</code>
* </dt>
* <dd>
* The subdirectory you're sharing. While the <code>name</code> attribute is a URI path
* segment, the <code>path</code> value is an actual subdirectory name. Notice that the
* value refers to a <b>subdirectory</b>, not an individual file or files. You can't
* share a single file by its file name, nor can you specify a subset of files using
* wildcards.
* </dd>
* </dl>
* <p>
* You must specify a child element of <code>&lt;paths&gt;</code> for each directory that contains
* files for which you want content URIs. For example, these XML elements specify two directories:
* <pre class="prettyprint">
*&lt;paths xmlns:android="http://schemas.android.com/apk/res/android"&gt;
* &lt;files-path name="my_images" path="images/"/&gt;
* &lt;files-path name="my_docs" path="docs/"/&gt;
*&lt;/paths&gt;
*</pre>
* <p>
* Put the <code>&lt;paths&gt;</code> element and its children in an XML file in your project.
* For example, you can add them to a new file called <code>res/xml/file_paths.xml</code>.
* To link this file to the FileProvider, add a
* <a href="{@docRoot}guide/topics/manifest/meta-data-element.html">&lt;meta-data&gt;</a> element
* as a child of the <code>&lt;provider&gt;</code> element that defines the FileProvider. Set the
* <code>&lt;meta-data&gt;</code> element's "android:name" attribute to
* <code>android.support.FILE_PROVIDER_PATHS</code>. Set the element's "android:resource" attribute
* to <code>&#64;xml/file_paths</code> (notice that you don't specify the <code>.xml</code>
* extension). For example:
* <pre class="prettyprint">
*&lt;provider
* android:name="androidx.core.content.FileProvider"
* android:authorities="com.mydomain.fileprovider"
* android:exported="false"
* android:grantUriPermissions="true"&gt;
* &lt;meta-data
* android:name="android.support.FILE_PROVIDER_PATHS"
* android:resource="&#64;xml/file_paths" /&gt;
*&lt;/provider&gt;
*</pre>
* <h3 id="GetUri">Generating the Content URI for a File</h3>
* <p>
* To share a file with another app using a content URI, your app has to generate the content URI.
* To generate the content URI, create a new {@link File} for the file, then pass the {@link File}
* to {@link #getUriForFile(Context, String, File) getUriForFile()}. You can send the content URI
* returned by {@link #getUriForFile(Context, String, File) getUriForFile()} to another app in an
* {@link Intent}. The client app that receives the content URI can open the file
* and access its contents by calling
* {@link android.content.ContentResolver#openFileDescriptor(Uri, String)
* ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}.
* <p>
* For example, suppose your app is offering files to other apps with a FileProvider that has the
* authority <code>com.mydomain.fileprovider</code>. To get a content URI for the file
* <code>default_image.jpg</code> in the <code>images/</code> subdirectory of your internal storage
* add the following code:
* <pre class="prettyprint">
*File imagePath = new File(Context.getFilesDir(), "images");
*File newFile = new File(imagePath, "default_image.jpg");
*Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
*</pre>
* As a result of the previous snippet,
* {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI
* <code>content://com.mydomain.fileprovider/my_images/default_image.jpg</code>.
* <h3 id="Permissions">Granting Temporary Permissions to a URI</h3>
* To grant an access permission to a content URI returned from
* {@link #getUriForFile(Context, String, File) getUriForFile()}, do one of the following:
* <ul>
* <li>
* Call the method
* {@link Context#grantUriPermission(String, Uri, int)
* Context.grantUriPermission(package, Uri, mode_flags)} for the <code>content://</code>
* {@link Uri}, using the desired mode flags. This grants temporary access permission for the
* content URI to the specified package, according to the value of the
* the <code>mode_flags</code> parameter, which you can set to
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}
* or both. The permission remains in effect until you revoke it by calling
* {@link Context#revokeUriPermission(Uri, int) revokeUriPermission()} or until the device
* reboots.
* </li>
* <li>
* Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}.
* </li>
* <li>
* Next, call the method {@link Intent#setFlags(int) Intent.setFlags()} with either
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} or
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} or both.
* </li>
* <li>
* Finally, send the {@link Intent} to
* another app. Most often, you do this by calling
* {@link android.app.Activity#setResult(int, Intent) setResult()}.
* <p>
* Permissions granted in an {@link Intent} remain in effect while the stack of the receiving
* {@link android.app.Activity} is active. When the stack finishes, the permissions are
* automatically removed. Permissions granted to one {@link android.app.Activity} in a client
* app are automatically extended to other components of that app.
* </p>
* </li>
* </ul>
* <h3 id="ServeUri">Serving a Content URI to Another App</h3>
* <p>
* There are a variety of ways to serve the content URI for a file to a client app. One common way
* is for the client app to start your app by calling
* {@link android.app.Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()},
* which sends an {@link Intent} to your app to start an {@link android.app.Activity} in your app.
* In response, your app can immediately return a content URI to the client app or present a user
* interface that allows the user to pick a file. In the latter case, once the user picks the file
* your app can return its content URI. In both cases, your app returns the content URI in an
* {@link Intent} sent via {@link android.app.Activity#setResult(int, Intent) setResult()}.
* </p>
* <p>
* You can also put the content URI in a {@link android.content.ClipData} object and then add the
* object to an {@link Intent} you send to a client app. To do this, call
* {@link Intent#setClipData(ClipData) Intent.setClipData()}. When you use this approach, you can
* add multiple {@link android.content.ClipData} objects to the {@link Intent}, each with its own
* content URI. When you call {@link Intent#setFlags(int) Intent.setFlags()} on the {@link Intent}
* to set temporary access permissions, the same permissions are applied to all of the content
* URIs.
* </p>
* <p class="note">
* <strong>Note:</strong> The {@link Intent#setClipData(ClipData) Intent.setClipData()} method is
* only available in platform version 16 (Android 4.1) and later. If you want to maintain
* compatibility with previous versions, you should send one content URI at a time in the
* {@link Intent}. Set the action to {@link Intent#ACTION_SEND} and put the URI in data by calling
* {@link Intent#setData setData()}.
* </p>
* <h3 id="">More Information</h3>
* <p>
* To learn more about FileProvider, see the Android training class
* <a href="{@docRoot}training/secure-file-sharing/index.html">Sharing Files Securely with URIs</a>.
* </p>
*/
public class FileProvider extends ContentProvider {
private static final String[] COLUMNS = {
OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
private static final String
META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS";
private static final String TAG_ROOT_PATH = "root-path";
private static final String TAG_FILES_PATH = "files-path";
private static final String TAG_CACHE_PATH = "cache-path";
private static final String TAG_EXTERNAL = "external-path";
private static final String TAG_EXTERNAL_FILES = "external-files-path";
private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
private static final String ATTR_NAME = "name";
private static final String ATTR_PATH = "path";
private static final File DEVICE_ROOT = new File("/");
@GuardedBy("sCache")
private static HashMap<String, PathStrategy> sCache = new HashMap<String, PathStrategy>();
private PathStrategy mStrategy;
/**
* The default FileProvider implementation does not need to be initialized. If you want to
* override this method, you must provide your own subclass of FileProvider.
*/
@Override
public boolean onCreate() {
return true;
}
/**
* After the FileProvider is instantiated, this method is called to provide the system with
* information about the provider.
*
* @param context A {@link Context} for the current component.
* @param info A {@link ProviderInfo} for the new provider.
*/
@Override
public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
super.attachInfo(context, info);
// Sanity check our security
if (info.exported) {
throw new SecurityException("Provider must not be exported");
}
if (!info.grantUriPermissions) {
throw new SecurityException("Provider must grant uri permissions");
}
mStrategy = getPathStrategy(context, info.authority);
}
/**
* Return a content URI for a given {@link File}. Specific temporary
* permissions for the content URI can be set with
* {@link Context#grantUriPermission(String, Uri, int)}, or added
* to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then
* {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a
* <code>content</code> {@link Uri} for file paths defined in their <code>&lt;paths&gt;</code>
* meta-data element. See the Class Overview for more information.
*
* @param context A {@link Context} for the current component.
* @param authority The authority of a {@link FileProvider} defined in a
* {@code <provider>} element in your app's manifest.
* @param file A {@link File} pointing to the filename for which you want a
* <code>content</code> {@link Uri}.
* @return A content URI for the file.
* @throws IllegalArgumentException When the given {@link File} is outside
* the paths supported by the provider.
*/
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
@NonNull File file) {
final PathStrategy strategy = getPathStrategy(context, authority);
return strategy.getUriForFile(file);
}
/**
* Use a content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file
* managed by the FileProvider.
* FileProvider reports the column names defined in {@link OpenableColumns}:
* <ul>
* <li>{@link OpenableColumns#DISPLAY_NAME}</li>
* <li>{@link OpenableColumns#SIZE}</li>
* </ul>
* For more information, see
* {@link ContentProvider#query(Uri, String[], String, String[], String)
* ContentProvider.query()}.
*
* @param uri A content URI returned by {@link #getUriForFile}.
* @param projection The list of columns to put into the {@link Cursor}. If null all columns are
* included.
* @param selection Selection criteria to apply. If null then all data that matches the content
* URI is returned.
* @param selectionArgs An array of {@link String}, containing arguments to bind to
* the <i>selection</i> parameter. The <i>query</i> method scans <i>selection</i> from left to
* right and iterates through <i>selectionArgs</i>, replacing the current "?" character in
* <i>selection</i> with the value at the current position in <i>selectionArgs</i>. The
* values are bound to <i>selection</i> as {@link String} values.
* @param sortOrder A {@link String} containing the column name(s) on which to sort
* the resulting {@link Cursor}.
* @return A {@link Cursor} containing the results of the query.
*
*/
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder) {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
if (projection == null) {
projection = COLUMNS;
}
String[] cols = new String[projection.length];
Object[] values = new Object[projection.length];
int i = 0;
for (String col : projection) {
if (OpenableColumns.DISPLAY_NAME.equals(col)) {
cols[i] = OpenableColumns.DISPLAY_NAME;
values[i++] = file.getName();
} else if (OpenableColumns.SIZE.equals(col)) {
cols[i] = OpenableColumns.SIZE;
values[i++] = file.length();
}
}
cols = copyOf(cols, i);
values = copyOf(values, i);
final MatrixCursor cursor = new MatrixCursor(cols, 1);
cursor.addRow(values);
return cursor;
}
/**
* Returns the MIME type of a content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
*
* @param uri A content URI returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @return If the associated file has an extension, the MIME type associated with that
* extension; otherwise <code>application/octet-stream</code>.
*/
@Override
public String getType(@NonNull Uri uri) {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
final int lastDot = file.getName().lastIndexOf('.');
if (lastDot >= 0) {
final String extension = file.getName().substring(lastDot + 1);
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mime != null) {
return mime;
}
}
return "application/octet-stream";
}
/**
* By default, this method throws an {@link UnsupportedOperationException}. You must
* subclass FileProvider if you want to provide different functionality.
*/
@Override
public Uri insert(@NonNull Uri uri, ContentValues values) {
throw new UnsupportedOperationException("No external inserts");
}
/**
* By default, this method throws an {@link UnsupportedOperationException}. You must
* subclass FileProvider if you want to provide different functionality.
*/
@Override
public int update(@NonNull Uri uri, ContentValues values, @Nullable String selection,
@Nullable String[] selectionArgs) {
throw new UnsupportedOperationException("No external updates");
}
/**
* Deletes the file associated with the specified content URI, as
* returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this
* method does <b>not</b> throw an {@link IOException}; you must check its return value.
*
* @param uri A content URI for a file, as returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @param selection Ignored. Set to {@code null}.
* @param selectionArgs Ignored. Set to {@code null}.
* @return 1 if the delete succeeds; otherwise, 0.
*/
@Override
public int delete(@NonNull Uri uri, @Nullable String selection,
@Nullable String[] selectionArgs) {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
return file.delete() ? 1 : 0;
}
/**
* By default, FileProvider automatically returns the
* {@link ParcelFileDescriptor} for a file associated with a <code>content://</code>
* {@link Uri}. To get the {@link ParcelFileDescriptor}, call
* {@link android.content.ContentResolver#openFileDescriptor(Uri, String)
* ContentResolver.openFileDescriptor}.
*
* To override this method, you must provide your own subclass of FileProvider.
*
* @param uri A content URI associated with a file, as returned by
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
* @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and
* write access, or "rwt" for read and write access that truncates any existing file.
* @return A new {@link ParcelFileDescriptor} with which you can access the file.
*/
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
// ContentProvider has already checked granted permissions
final File file = mStrategy.getFileForUri(uri);
final int fileMode = modeToMode(mode);
return ParcelFileDescriptor.open(file, fileMode);
}
/**
* Return {@link PathStrategy} for given authority, either by parsing or
* returning from cache.
*/
private static PathStrategy getPathStrategy(Context context, String authority) {
PathStrategy strat;
synchronized (sCache) {
strat = sCache.get(authority);
if (strat == null) {
try {
strat = parsePathStrategy(context, authority);
} catch (IOException e) {
throw new IllegalArgumentException(
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
} catch (XmlPullParserException e) {
throw new IllegalArgumentException(
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
}
sCache.put(authority, strat);
}
}
return strat;
}
/**
* Parse and return {@link PathStrategy} for given authority as defined in
* {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}.
*
* @see #getPathStrategy(Context, String)
*/
private static PathStrategy parsePathStrategy(Context context, String authority)
throws IOException, XmlPullParserException {
final SimplePathStrategy strat = new SimplePathStrategy(authority);
final ProviderInfo info = context.getPackageManager()
.resolveContentProvider(authority, PackageManager.GET_META_DATA);
if (info == null) {
throw new IllegalArgumentException(
"Couldn't find meta-data for provider with authority " + authority);
}
final XmlResourceParser in = info.loadXmlMetaData(
context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
if (in == null) {
throw new IllegalArgumentException(
"Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
}
int type;
while ((type = in.next()) != END_DOCUMENT) {
if (type == START_TAG) {
final String tag = in.getName();
final String name = in.getAttributeValue(null, ATTR_NAME);
String path = in.getAttributeValue(null, ATTR_PATH);
File target = null;
if (TAG_ROOT_PATH.equals(tag)) {
target = DEVICE_ROOT;
} else if (TAG_FILES_PATH.equals(tag)) {
target = context.getFilesDir();
} else if (TAG_CACHE_PATH.equals(tag)) {
target = context.getCacheDir();
} else if (TAG_EXTERNAL.equals(tag)) {
target = Environment.getExternalStorageDirectory();
} else if (TAG_EXTERNAL_FILES.equals(tag)) {
File[] externalFilesDirs = context.getExternalFilesDirs(null);
if (externalFilesDirs.length > 0) {
target = externalFilesDirs[0];
}
} else if (TAG_EXTERNAL_CACHE.equals(tag)) {
File[] externalCacheDirs = context.getExternalCacheDirs();
if (externalCacheDirs.length > 0) {
target = externalCacheDirs[0];
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& TAG_EXTERNAL_MEDIA.equals(tag)) {
File[] externalMediaDirs = context.getExternalMediaDirs();
if (externalMediaDirs.length > 0) {
target = externalMediaDirs[0];
}
}
if (target != null) {
strat.addRoot(name, buildPath(target, path));
}
}
}
return strat;
}
/**
* Strategy for mapping between {@link File} and {@link Uri}.
* <p>
* Strategies must be symmetric so that mapping a {@link File} to a
* {@link Uri} and then back to a {@link File} points at the original
* target.
* <p>
* Strategies must remain consistent across app launches, and not rely on
* dynamic state. This ensures that any generated {@link Uri} can still be
* resolved if your process is killed and later restarted.
*
* @see SimplePathStrategy
*/
interface PathStrategy {
/**
* Return a {@link Uri} that represents the given {@link File}.
*/
Uri getUriForFile(File file);
/**
* Return a {@link File} that represents the given {@link Uri}.
*/
File getFileForUri(Uri uri);
}
/**
* Strategy that provides access to files living under a narrow whitelist of
* filesystem roots. It will throw {@link SecurityException} if callers try
* accessing files outside the configured roots.
* <p>
* For example, if configured with
* {@code addRoot("myfiles", context.getFilesDir())}, then
* {@code context.getFileStreamPath("foo.txt")} would map to
* {@code content://myauthority/myfiles/foo.txt}.
*/
static class SimplePathStrategy implements PathStrategy {
private final String mAuthority;
private final HashMap<String, File> mRoots = new HashMap<String, File>();
SimplePathStrategy(String authority) {
mAuthority = authority;
}
/**
* Add a mapping from a name to a filesystem root. The provider only offers
* access to files that live under configured roots.
*/
void addRoot(String name, File root) {
if (TextUtils.isEmpty(name)) {
throw new IllegalArgumentException("Name must not be empty");
}
try {
// Resolve to canonical path to keep path checking fast
root = root.getCanonicalFile();
} catch (IOException e) {
throw new IllegalArgumentException(
"Failed to resolve canonical path for " + root, e);
}
mRoots.put(name, root);
}
@Override
public Uri getUriForFile(File file) {
String path;
try {
path = file.getCanonicalPath();
} catch (IOException e) {
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
}
// Find the most-specific root path
Map.Entry<String, File> mostSpecific = null;
for (Map.Entry<String, File> root : mRoots.entrySet()) {
final String rootPath = root.getValue().getPath();
if (path.startsWith(rootPath) && (mostSpecific == null
|| rootPath.length() > mostSpecific.getValue().getPath().length())) {
mostSpecific = root;
}
}
if (mostSpecific == null) {
throw new IllegalArgumentException(
"Failed to find configured root that contains " + path);
}
// Start at first char of path under root
final String rootPath = mostSpecific.getValue().getPath();
if (rootPath.endsWith("/")) {
path = path.substring(rootPath.length());
} else {
path = path.substring(rootPath.length() + 1);
}
// Encode the tag and path separately
path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/");
return new Uri.Builder().scheme("content")
.authority(mAuthority).encodedPath(path).build();
}
@Override
public File getFileForUri(Uri uri) {
String path = uri.getEncodedPath();
final int splitIndex = path.indexOf('/', 1);
final String tag = Uri.decode(path.substring(1, splitIndex));
path = Uri.decode(path.substring(splitIndex + 1));
final File root = mRoots.get(tag);
if (root == null) {
throw new IllegalArgumentException("Unable to find configured root for " + uri);
}
File file = new File(root, path);
try {
file = file.getCanonicalFile();
} catch (IOException e) {
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
}
if (!file.getPath().startsWith(root.getPath())) {
throw new SecurityException("Resolved path jumped beyond configured root");
}
return file;
}
}
/**
* Copied from ContentResolver.java
*/
private static int modeToMode(String mode) {
int modeBits;
if ("r".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
} else if ("w".equals(mode) || "wt".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
| ParcelFileDescriptor.MODE_CREATE
| ParcelFileDescriptor.MODE_TRUNCATE;
} else if ("wa".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
| ParcelFileDescriptor.MODE_CREATE
| ParcelFileDescriptor.MODE_APPEND;
} else if ("rw".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
| ParcelFileDescriptor.MODE_CREATE;
} else if ("rwt".equals(mode)) {
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
| ParcelFileDescriptor.MODE_CREATE
| ParcelFileDescriptor.MODE_TRUNCATE;
} else {
throw new IllegalArgumentException("Invalid mode: " + mode);
}
return modeBits;
}
private static File buildPath(File base, String... segments) {
File cur = base;
for (String segment : segments) {
if (segment != null) {
cur = new File(cur, segment);
}
}
return cur;
}
private static String[] copyOf(String[] original, int newLength) {
final String[] result = new String[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
private static Object[] copyOf(Object[] original, int newLength) {
final Object[] result = new Object[newLength];
System.arraycopy(original, 0, result, 0, newLength);
return result;
}
}

View file

@ -1,7 +1,6 @@
package org.joinmastodon.android; package org.joinmastodon.android;
import static org.joinmastodon.android.api.MastodonAPIController.gson; import static org.joinmastodon.android.api.MastodonAPIController.gson;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference.MATERIAL3;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@ -14,12 +13,12 @@ import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference;
import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.utils.ColorPalette;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.util.ArrayList; import java.util.ArrayList;
@ -28,9 +27,6 @@ import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
public class GlobalUserPreferences{ public class GlobalUserPreferences{
private static final String TAG="GlobalUserPreferences"; private static final String TAG="GlobalUserPreferences";
@ -45,6 +41,7 @@ public class GlobalUserPreferences{
public static boolean showNewPostsButton; public static boolean showNewPostsButton;
public static boolean toolbarMarquee; public static boolean toolbarMarquee;
public static boolean disableSwipe; public static boolean disableSwipe;
public static boolean voteButtonForSingleChoice;
public static boolean enableDeleteNotifications; public static boolean enableDeleteNotifications;
public static boolean translateButtonOpenedOnly; public static boolean translateButtonOpenedOnly;
public static boolean uniformNotificationIcon; public static boolean uniformNotificationIcon;
@ -56,42 +53,32 @@ public class GlobalUserPreferences{
public static boolean collapseLongPosts; public static boolean collapseLongPosts;
public static boolean spectatorMode; public static boolean spectatorMode;
public static boolean autoHideFab; public static boolean autoHideFab;
public static boolean compactReblogReplyLine;
public static boolean allowRemoteLoading; public static boolean allowRemoteLoading;
public static boolean forwardReportDefault;
public static AutoRevealMode autoRevealEqualSpoilers; public static AutoRevealMode autoRevealEqualSpoilers;
public static ColorPreference color;
public static boolean disableM3PillActiveIndicator; public static boolean disableM3PillActiveIndicator;
public static boolean showNavigationLabels; public static boolean showNavigationLabels;
public static boolean displayPronounsInTimelines, displayPronounsInThreads, displayPronounsInUserListings; public static boolean displayPronounsInTimelines, displayPronounsInThreads, displayPronounsInUserListings;
public static boolean overlayMedia; public static boolean overlayMedia;
public static boolean showSuicideHelp;
public static boolean underlinedLinks;
public static ColorPreference color;
public static boolean likeIcon;
// MOSHIDON // MOSHIDON
public static boolean showDividers; public static boolean showDividers;
public static boolean relocatePublishButton; public static boolean relocatePublishButton;
public static boolean defaultToUnlistedReplies; public static boolean defaultToUnlistedReplies;
public static boolean doubleTapToSearch;
public static boolean doubleTapToSwipe; public static boolean doubleTapToSwipe;
public static boolean confirmBeforeReblog; public static boolean confirmBeforeReblog;
public static boolean hapticFeedback; public static boolean hapticFeedback;
public static boolean replyLineAboveHeader; public static boolean replyLineAboveHeader;
public static boolean swapBookmarkWithBoostAction; public static boolean swapBookmarkWithBoostAction;
public static boolean loadRemoteAccountFollowers;
public static boolean mentionRebloggerAutomatically; public static boolean mentionRebloggerAutomatically;
public static boolean showPostsWithoutAlt;
public static boolean showMediaPreview;
public static boolean removeTrackingParams;
public static boolean enhanceTextSize;
public static SharedPreferences getPrefs(){ public static SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
} }
private static SharedPreferences getPreReplyPrefs(){
return MastodonApp.context.getSharedPreferences("pre_reply_sheets", Context.MODE_PRIVATE);
}
public static <T> T fromJson(String json, Type type, T orElse){ public static <T> T fromJson(String json, Type type, T orElse){
if(json==null) return orElse; if(json==null) return orElse;
try{ try{
@ -124,6 +111,7 @@ public class GlobalUserPreferences{
showNewPostsButton=prefs.getBoolean("showNewPostsButton", true); showNewPostsButton=prefs.getBoolean("showNewPostsButton", true);
toolbarMarquee=prefs.getBoolean("toolbarMarquee", true); toolbarMarquee=prefs.getBoolean("toolbarMarquee", true);
disableSwipe=prefs.getBoolean("disableSwipe", false); disableSwipe=prefs.getBoolean("disableSwipe", false);
voteButtonForSingleChoice=prefs.getBoolean("voteButtonForSingleChoice", true);
enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false); enableDeleteNotifications=prefs.getBoolean("enableDeleteNotifications", false);
translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false); translateButtonOpenedOnly=prefs.getBoolean("translateButtonOpenedOnly", false);
uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false);
@ -135,38 +123,33 @@ public class GlobalUserPreferences{
collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); collapseLongPosts=prefs.getBoolean("collapseLongPosts", true);
spectatorMode=prefs.getBoolean("spectatorMode", false); spectatorMode=prefs.getBoolean("spectatorMode", false);
autoHideFab=prefs.getBoolean("autoHideFab", true); autoHideFab=prefs.getBoolean("autoHideFab", true);
compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true);
allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true);
autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name())); autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name()));
forwardReportDefault=prefs.getBoolean("forwardReportDefault", true);
disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false); disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false);
showNavigationLabels=prefs.getBoolean("showNavigationLabels", true); showNavigationLabels=prefs.getBoolean("showNavigationLabels", true);
displayPronounsInTimelines=prefs.getBoolean("displayPronounsInTimelines", true); displayPronounsInTimelines=prefs.getBoolean("displayPronounsInTimelines", true);
displayPronounsInThreads=prefs.getBoolean("displayPronounsInThreads", true); displayPronounsInThreads=prefs.getBoolean("displayPronounsInThreads", true);
displayPronounsInUserListings=prefs.getBoolean("displayPronounsInUserListings", true); displayPronounsInUserListings=prefs.getBoolean("displayPronounsInUserListings", true);
overlayMedia=prefs.getBoolean("overlayMedia", false); overlayMedia=prefs.getBoolean("overlayMedia", false);
showSuicideHelp=prefs.getBoolean("showSuicideHelp", true);
underlinedLinks=prefs.getBoolean("underlinedLinks", true);
color=ColorPreference.valueOf(prefs.getString("color", MATERIAL3.name()));
likeIcon=prefs.getBoolean("likeIcon", false);
// MOSHIDON // MOSHIDON
uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false);
showDividers =prefs.getBoolean("showDividers", false); showDividers =prefs.getBoolean("showDividers", false);
relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); relocatePublishButton=prefs.getBoolean("relocatePublishButton", true);
compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true);
defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false); defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false);
doubleTapToSearch =prefs.getBoolean("doubleTapToSearch", true);
doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true); doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true);
replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true);
confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false);
hapticFeedback=prefs.getBoolean("hapticFeedback", true); hapticFeedback=prefs.getBoolean("hapticFeedback", true);
swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false); swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false);
loadRemoteAccountFollowers=prefs.getBoolean("loadRemoteAccountFollowers", true);
mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false); mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false);
showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true);
showMediaPreview=prefs.getBoolean("showMediaPreview", true);
removeTrackingParams=prefs.getBoolean("removeTrackingParams", true);
enhanceTextSize=prefs.getBoolean("enhanceTextSize", false);
theme=ThemePreference.values()[prefs.getInt("theme", 0)]; theme=ThemePreference.values()[prefs.getInt("theme", 0)];
if (prefs.contains("prefixRepliesWithRe")) { if (prefs.contains("prefixRepliesWithRe")) {
prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false)
? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER; ? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER;
@ -176,11 +159,18 @@ public class GlobalUserPreferences{
.apply(); .apply();
} }
int migrationLevel=prefs.getInt("migrationLevel", BuildConfig.VERSION_CODE); try {
if(migrationLevel < 61) if(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){
migrateToUpstreamVersion61(); color=ColorPreference.valueOf(prefs.getString("color", ColorPreference.MATERIAL3.name()));
if(migrationLevel < BuildConfig.VERSION_CODE) }else{
prefs.edit().putInt("migrationLevel", BuildConfig.VERSION_CODE).apply(); color=ColorPreference.valueOf(prefs.getString("color", ColorPreference.PURPLE.name()));
}
} catch (IllegalArgumentException|ClassCastException ignored) {
// invalid color name or color was previously saved as integer
color=ColorPreference.PURPLE;
}
if(prefs.getInt("migrationLevel", 0) < 61) migrateToUpstreamVersion61();
} }
public static void save(){ public static void save(){
@ -210,89 +200,35 @@ public class GlobalUserPreferences{
.putBoolean("collapseLongPosts", collapseLongPosts) .putBoolean("collapseLongPosts", collapseLongPosts)
.putBoolean("spectatorMode", spectatorMode) .putBoolean("spectatorMode", spectatorMode)
.putBoolean("autoHideFab", autoHideFab) .putBoolean("autoHideFab", autoHideFab)
.putBoolean("compactReblogReplyLine", compactReblogReplyLine)
.putString("color", color.name())
.putBoolean("allowRemoteLoading", allowRemoteLoading) .putBoolean("allowRemoteLoading", allowRemoteLoading)
.putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name()) .putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name())
.putBoolean("forwardReportDefault", forwardReportDefault)
.putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator) .putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator)
.putBoolean("showNavigationLabels", showNavigationLabels) .putBoolean("showNavigationLabels", showNavigationLabels)
.putBoolean("displayPronounsInTimelines", displayPronounsInTimelines) .putBoolean("displayPronounsInTimelines", displayPronounsInTimelines)
.putBoolean("displayPronounsInThreads", displayPronounsInThreads) .putBoolean("displayPronounsInThreads", displayPronounsInThreads)
.putBoolean("displayPronounsInUserListings", displayPronounsInUserListings) .putBoolean("displayPronounsInUserListings", displayPronounsInUserListings)
.putBoolean("overlayMedia", overlayMedia) .putBoolean("overlayMedia", overlayMedia)
.putBoolean("showSuicideHelp", showSuicideHelp)
.putBoolean("underlinedLinks", underlinedLinks)
.putString("color", color.name())
.putBoolean("likeIcon", likeIcon)
// MOSHIDON // MOSHIDON
.putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies) .putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies)
.putBoolean("doubleTapToSearch", doubleTapToSearch)
.putBoolean("doubleTapToSwipe", doubleTapToSwipe) .putBoolean("doubleTapToSwipe", doubleTapToSwipe)
.putBoolean("compactReblogReplyLine", compactReblogReplyLine)
.putBoolean("replyLineAboveHeader", replyLineAboveHeader) .putBoolean("replyLineAboveHeader", replyLineAboveHeader)
.putBoolean("confirmBeforeReblog", confirmBeforeReblog) .putBoolean("confirmBeforeReblog", confirmBeforeReblog)
.putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction) .putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction)
.putBoolean("hapticFeedback", hapticFeedback) .putBoolean("loadRemoteAccountFollowers", loadRemoteAccountFollowers)
.putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically) .putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically)
.putBoolean("showDividers", showDividers) .putBoolean("showDividers", showDividers)
.putBoolean("relocatePublishButton", relocatePublishButton) .putBoolean("relocatePublishButton", relocatePublishButton)
.putBoolean("enableDeleteNotifications", enableDeleteNotifications) .putBoolean("enableDeleteNotifications", enableDeleteNotifications)
.putBoolean("showPostsWithoutAlt", showPostsWithoutAlt) .putInt("theme", theme.ordinal())
.putBoolean("showMediaPreview", showMediaPreview)
.putBoolean("removeTrackingParams", removeTrackingParams)
.putBoolean("enhanceTextSize", enhanceTextSize)
.apply(); .apply();
} }
public static boolean isOptedOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
if(getPreReplyPrefs().getBoolean("opt_out_"+type, false))
return true;
if(account==null)
return false;
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
return getPreReplyPrefs().getBoolean("opt_out_"+type+"_"+accountKey.toLowerCase(), false);
}
public static void optOutOfPreReplySheet(PreReplySheetType type, Account account, String accountID){
String key;
if(account==null){
key="opt_out_"+type;
}else{
String accountKey=account.acct;
if(!accountKey.contains("@"))
accountKey+="@"+AccountSessionManager.get(accountID).domain;
key="opt_out_"+type+"_"+accountKey.toLowerCase();
}
getPreReplyPrefs().edit().putBoolean(key, true).apply();
}
public enum ThemePreference{
AUTO,
LIGHT,
DARK
}
public enum PreReplySheetType{
OLD_POST,
NON_MUTUAL
}
public enum AutoRevealMode {
NEVER,
THREADS,
DISCUSSIONS
}
public enum PrefixRepliesMode {
NEVER,
ALWAYS,
TO_OTHERS
}
//region preferences migrations
private static void migrateToUpstreamVersion61(){ private static void migrateToUpstreamVersion61(){
Log.d(TAG, "Migrating preferences to upstream version 61!!"); Log.d(TAG, "Migrating preferences to upstream version 61!!");
@ -339,7 +275,53 @@ public class GlobalUserPreferences{
localPrefs.save(); localPrefs.save();
} }
prefs.edit().putInt("migrationLevel", 61).apply();
} }
//endregion public enum ColorPreference{
MATERIAL3,
PINK,
PURPLE,
GREEN,
BLUE,
BROWN,
RED,
YELLOW,
NORD,
WHITE;
public @StringRes int getName() {
return switch(this){
case MATERIAL3 -> R.string.sk_color_palette_material3;
case PINK -> R.string.sk_color_palette_pink;
case PURPLE -> R.string.sk_color_palette_purple;
case GREEN -> R.string.sk_color_palette_green;
case BLUE -> R.string.sk_color_palette_blue;
case BROWN -> R.string.sk_color_palette_brown;
case RED -> R.string.sk_color_palette_red;
case YELLOW -> R.string.sk_color_palette_yellow;
case NORD -> R.string.mo_color_palette_nord;
case WHITE -> R.string.mo_color_palette_black_and_white;
};
}
}
public enum ThemePreference{
AUTO,
LIGHT,
DARK
}
public enum AutoRevealMode {
NEVER,
THREADS,
DISCUSSIONS
}
public enum PrefixRepliesMode {
NEVER,
ALWAYS,
TO_OTHERS
}
} }

View file

@ -7,20 +7,15 @@ import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.app.Fragment; import android.app.Fragment;
import android.app.assist.AssistContent; import android.app.assist.AssistContent;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import android.net.Uri; import android.net.Uri;
import android.os.BadParcelableException;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.DisplayMetrics;
import android.util.Log; import android.util.Log;
import android.util.TypedValue;
import android.view.View; import android.view.View;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.Toast; import android.widget.Toast;
@ -44,46 +39,62 @@ import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels; import org.parceler.Parcels;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Instant;
import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.Nav; import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent { public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent {
private static final String TAG="MainActivity";
@Override @Override
protected void onCreate(@Nullable Bundle savedInstanceState){ protected void onCreate(@Nullable Bundle savedInstanceState){
AccountSession session=getCurrentSession(); UiUtils.setUserPreferredTheme(this);
UiUtils.setUserPreferredTheme(this, session);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
Thread.UncaughtExceptionHandler defaultHandler=Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((t, e)->{
File file=new File(MastodonApp.context.getFilesDir(), "crash.log");
try(FileOutputStream out=new FileOutputStream(file)){
PrintWriter writer=new PrintWriter(out);
writer.println(BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")");
writer.println(Instant.now().toString());
writer.println();
e.printStackTrace(writer);
writer.flush();
}catch(IOException x){
Log.e(TAG, "Error writing crash.log", x);
}finally{
defaultHandler.uncaughtException(t, e);
}
});
if(savedInstanceState==null){ if(savedInstanceState==null){
restartHomeFragment(); if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){
showFragmentClearingBackStack(new CustomWelcomeFragment());
}else{
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.hasExtra("fromExternalShare")) {
AccountSessionManager.getInstance()
.setLastActiveAccountID(intent.getStringExtra("account"));
AccountSessionManager.getInstance().maybeUpdateLocalInfo(
AccountSessionManager.getInstance().getLastActiveAccount());
showFragmentForExternalShare(intent.getExtras());
return;
}
boolean fromNotification = intent.getBooleanExtra("fromNotification", false);
boolean hasNotification = intent.hasExtra("notification");
if(fromNotification){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!hasNotification) args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
AccountSessionManager.getInstance().maybeUpdateLocalInfo(session);
args.putString("account", session.getID());
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
fragment.setArguments(args);
if(fromNotification && hasNotification){
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
showFragmentForNotification(notification, session.getID());
} else if (intent.getBooleanExtra("compose", false)){
showCompose();
} else if (Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null);
} else {
showFragmentClearingBackStack(fragment);
maybeRequestNotificationsPermission();
}
}
} }
if(GithubSelfUpdater.needSelfUpdating()){ if(GithubSelfUpdater.needSelfUpdating()){
@ -115,6 +126,8 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
fragment.setArguments(args); fragment.setArguments(args);
showFragmentClearingBackStack(fragment); showFragmentClearingBackStack(fragment);
} }
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){ }else if(Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null); handleURL(intent.getData(), null);
}/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){ }/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
@ -134,11 +147,11 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
session=AccountSessionManager.get(accountID); session=AccountSessionManager.get(accountID);
if(session==null || !session.activated) if(session==null || !session.activated)
return; return;
openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false, null); openSearchQuery(uri.toString(), session.getID(), R.string.opening_link, false);
} }
public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch, GetSearchResults.Type type){ public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch){
new GetSearchResults(q, type, true, null, 0, 0) new GetSearchResults(q, null, true)
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(SearchResults result){ public void onSuccess(SearchResults result){
@ -189,6 +202,17 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
showFragment(fragment); showFragment(fragment);
} }
private void showCompose(){
AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount();
if(session==null || !session.activated)
return;
ComposeFragment compose=new ComposeFragment();
Bundle composeArgs=new Bundle();
composeArgs.putString("account", session.getID());
compose.setArguments(composeArgs);
showFragment(compose);
}
private void maybeRequestNotificationsPermission(){ private void maybeRequestNotificationsPermission(){
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100); requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100);
@ -261,101 +285,4 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
Fragment fragment = getCurrentFragment(); Fragment fragment = getCurrentFragment();
if (fragment != null) callFragmentToProvideAssistContent(fragment, assistContent); if (fragment != null) callFragmentToProvideAssistContent(fragment, assistContent);
} }
public AccountSession getCurrentSession(){
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.hasExtra("fromExternalShare")) {
return AccountSessionManager.getInstance()
.getAccount(intent.getStringExtra("account"));
}
boolean fromNotification = intent.getBooleanExtra("fromNotification", false);
boolean hasNotification = intent.hasExtra("notification");
if(fromNotification){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!hasNotification) args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
return session;
}
public void restartActivity(){
finish();
startActivity(new Intent(this, MainActivity.class));
}
public void restartHomeFragment(){
if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){
showFragmentClearingBackStack(new CustomWelcomeFragment());
}else{
AccountSession session;
Bundle args=new Bundle();
Intent intent=getIntent();
if(intent.hasExtra("fromExternalShare")) {
AccountSessionManager.getInstance()
.setLastActiveAccountID(intent.getStringExtra("account"));
AccountSessionManager.getInstance().maybeUpdateLocalInfo(
AccountSessionManager.getInstance().getLastActiveAccount());
showFragmentForExternalShare(intent.getExtras());
return;
}
boolean fromNotification = intent.getBooleanExtra("fromNotification", false);
boolean hasNotification = intent.hasExtra("notification");
if(fromNotification){
String accountID=intent.getStringExtra("accountID");
try{
session=AccountSessionManager.getInstance().getAccount(accountID);
if(!hasNotification) args.putString("tab", "notifications");
}catch(IllegalStateException x){
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
}else{
session=AccountSessionManager.getInstance().getLastActiveAccount();
}
AccountSessionManager.getInstance().maybeUpdateLocalInfo(session);
args.putString("account", session.getID());
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
fragment.setArguments(args);
if(fromNotification && hasNotification){
// Parcelables might not be compatible across app versions so this protects against possible crashes
// when a notification was received, then the app was updated, and then the user opened the notification
try{
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
showFragmentForNotification(notification, session.getID());
}catch(BadParcelableException x){
Log.w(TAG, x);
}
} else if (Intent.ACTION_VIEW.equals(intent.getAction())){
handleURL(intent.getData(), null);
} else {
showFragmentClearingBackStack(fragment);
maybeRequestNotificationsPermission();
}
}
}
@Override
protected void attachBaseContext(Context base) {
if (!GlobalUserPreferences.enhanceTextSize) {
super.attachBaseContext(base);
return;
}
final Configuration override = new Configuration(base.getResources().getConfiguration());
// This is the font multiplier, which should be multiplied by, because the system settings also play a role here
override.fontScale *= 1.15f;
final Context newBase = base.createConfigurationContext(override);
super.attachBaseContext(newBase);
}
} }

View file

@ -6,7 +6,6 @@ import android.content.Context;
import android.webkit.WebView; import android.webkit.WebView;
import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.utils.UnifiedPushHelper;
import me.grishka.appkit.imageloader.ImageCache; import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.NetworkUtils; import me.grishka.appkit.utils.NetworkUtils;
@ -26,13 +25,9 @@ public class MastodonApp extends Application{
params.diskCacheSize=100*1024*1024; params.diskCacheSize=100*1024*1024;
params.maxMemoryCacheSize=Integer.MAX_VALUE; params.maxMemoryCacheSize=Integer.MAX_VALUE;
ImageCache.setParams(params); ImageCache.setParams(params);
NetworkUtils.setUserAgent("MoshidonAndroid/"+BuildConfig.VERSION_NAME); NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
if (UnifiedPushHelper.isUnifiedPushEnabled(this)){ PushSubscriptionManager.tryRegisterFCM();
UnifiedPushHelper.registerAllAccounts(this);
} else {
PushSubscriptionManager.tryRegisterFCM();
}
GlobalUserPreferences.load(); GlobalUserPreferences.load();
if(BuildConfig.DEBUG){ if(BuildConfig.DEBUG){
WebView.setWebContentsDebuggingEnabled(true); WebView.setWebContentsDebuggingEnabled(true);

View file

@ -17,6 +17,7 @@ import android.graphics.drawable.Drawable;
import android.opengl.Visibility; import android.opengl.Visibility;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -37,6 +38,7 @@ import org.joinmastodon.android.model.PushNotification;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels; import org.parceler.Parcels;
@ -101,7 +103,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{
} }
String accountID=account.getID(); String accountID=account.getID();
PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s);
new GetNotificationByID(pn.notificationId) new GetNotificationByID(pn.notificationId+"")
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(org.joinmastodon.android.model.Notification result){ public void onSuccess(org.joinmastodon.android.model.Notification result){
@ -133,11 +135,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{
if(intent.hasExtra("notification")){ if(intent.hasExtra("notification")){
org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
String statusID=notification.status.id;
String statusID = null;
if(notification != null && notification.status != null)
statusID=notification.status.id;
if (statusID != null) { if (statusID != null) {
AccountSessionManager accountSessionManager = AccountSessionManager.getInstance(); AccountSessionManager accountSessionManager = AccountSessionManager.getInstance();
Preferences preferences = accountSessionManager.getAccount(accountID).preferences; Preferences preferences = accountSessionManager.getAccount(accountID).preferences;
@ -158,12 +156,12 @@ public class PushNotificationReceiver extends BroadcastReceiver{
} }
} }
public void notifyUnifiedPush(Context context, AccountSession account, org.joinmastodon.android.model.Notification notification) { public void notifyUnifiedPush(Context context, String accountID, org.joinmastodon.android.model.Notification notification) {
// push notifications are only created from the official push notification, so we create a fake from by transforming the notification // push notifications are only created from the official push notification, so we create a fake from by transforming the notification
PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, account, notification), account.getID(), notification); PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, notification), accountID, notification);
} }
void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){
NotificationManager nm=context.getSystemService(NotificationManager.class); NotificationManager nm=context.getSystemService(NotificationManager.class);
AccountSession session=AccountSessionManager.get(accountID); AccountSession session=AccountSessionManager.get(accountID);
Account self=session.self; Account self=session.self;
@ -184,8 +182,6 @@ public class PushNotificationReceiver extends BroadcastReceiver{
List<NotificationChannel> channels=Arrays.stream(PushNotification.Type.values()) List<NotificationChannel> channels=Arrays.stream(PushNotification.Type.values())
.map(type->{ .map(type->{
NotificationChannel channel=new NotificationChannel(accountID+"_"+type, context.getString(type.localizedName), NotificationManager.IMPORTANCE_DEFAULT); NotificationChannel channel=new NotificationChannel(accountID+"_"+type, context.getString(type.localizedName), NotificationManager.IMPORTANCE_DEFAULT);
channel.setLightColor(context.getColor(R.color.primary_700));
channel.enableLights(true);
channel.setGroup(accountID); channel.setGroup(accountID);
return channel; return channel;
}) })
@ -215,12 +211,11 @@ public class PushNotificationReceiver extends BroadcastReceiver{
.setShowWhen(true) .setShowWhen(true)
.setCategory(Notification.CATEGORY_SOCIAL) .setCategory(Notification.CATEGORY_SOCIAL)
.setAutoCancel(true) .setAutoCancel(true)
.setLights(context.getColor(R.color.primary_700), 500, 1000)
.setColor(context.getColor(R.color.shortcut_icon_background)); .setColor(context.getColor(R.color.shortcut_icon_background));
if (!GlobalUserPreferences.uniformNotificationIcon) { if (!GlobalUserPreferences.uniformNotificationIcon) {
builder.setSmallIcon(switch (pn.notificationType) { builder.setSmallIcon(switch (pn.notificationType) {
case FAVORITE -> GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_24_filled : R.drawable.ic_fluent_star_24_filled; case FAVORITE -> R.drawable.ic_fluent_star_24_filled;
case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled; case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled;
case FOLLOW -> R.drawable.ic_fluent_person_add_24_filled; case FOLLOW -> R.drawable.ic_fluent_person_add_24_filled;
case MENTION -> R.drawable.ic_fluent_mention_24_filled; case MENTION -> R.drawable.ic_fluent_mention_24_filled;
@ -341,11 +336,11 @@ public class PushNotificationReceiver extends BroadcastReceiver{
CreateStatus.Request req=new CreateStatus.Request(); CreateStatus.Request req=new CreateStatus.Request();
req.status = initialText + input.toString(); req.status = initialText + input.toString();
req.language = notification.status.language; req.language = preferences.postingDefaultLanguage;
req.visibility = (notification.status.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : notification.status.visibility); req.visibility = preferences.postingDefaultVisibility;
req.inReplyToId = notification.status.id; req.inReplyToId = notification.status.id;
if (notification.status.hasSpoiler() && if (!notification.status.spoilerText.isEmpty() &&
(GlobalUserPreferences.prefixReplies == ALWAYS (GlobalUserPreferences.prefixReplies == ALWAYS
|| (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(notification.status.account.id))) || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(notification.status.account.id)))
&& !notification.status.spoilerText.startsWith("re: ")) { && !notification.status.spoilerText.startsWith("re: ")) {

View file

@ -1,38 +0,0 @@
package org.joinmastodon.android;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import java.io.FileNotFoundException;
import java.util.Arrays;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class TweakedFileProvider extends FileProvider{
private static final String TAG="TweakedFileProvider";
@Override
public String getType(@NonNull Uri uri){
Log.d(TAG, "getType() called with: uri = ["+uri+"]");
if(uri.getPathSegments().get(0).equals("image_cache")){
Log.i(TAG, "getType: HERE!");
return "image/jpeg"; // might as well be a png but image decoding APIs don't care, needs to be image/* though
}
return super.getType(uri);
}
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){
Log.d(TAG, "query() called with: uri = ["+uri+"], projection = ["+Arrays.toString(projection)+"], selection = ["+selection+"], selectionArgs = ["+Arrays.toString(selectionArgs)+"], sortOrder = ["+sortOrder+"]");
return super.query(uri, projection, selection, selectionArgs, sortOrder);
}
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{
Log.d(TAG, "openFile() called with: uri = ["+uri+"], mode = ["+mode+"]");
return super.openFile(uri, mode);
}
}

View file

@ -5,22 +5,14 @@ import android.util.Log;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.notifications.GetNotificationByID;
import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.PushNotification;
import org.unifiedpush.android.connector.FailedReason;
import org.unifiedpush.android.connector.MessagingReceiver; import org.unifiedpush.android.connector.MessagingReceiver;
import org.unifiedpush.android.connector.data.PublicKeySet;
import org.unifiedpush.android.connector.data.PushEndpoint;
import org.unifiedpush.android.connector.data.PushMessage;
import java.util.List; import java.util.List;
import java.util.function.Function;
import kotlin.text.Charsets;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
@ -32,27 +24,20 @@ public class UnifiedPushNotificationReceiver extends MessagingReceiver{
} }
@Override @Override
public void onNewEndpoint(@NotNull Context context, @NotNull PushEndpoint endpoint, @NotNull String instance) { public void onNewEndpoint(@NotNull Context context, @NotNull String endpoint, @NotNull String instance) {
// Called when a new endpoint be used for sending push messages // Called when a new endpoint be used for sending push messages
Log.d(TAG, "onNewEndpoint: New Endpoint " + endpoint.getUrl() + " for "+ instance); Log.d(TAG, "onNewEndpoint: New Endpoint " + endpoint + " for "+ instance);
AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount();
if (account != null) { if (account != null)
PublicKeySet ks = endpoint.getPubKeySet(); account.getPushSubscriptionManager().registerAccountForPush(null);
if (ks != null){
account.getPushSubscriptionManager().registerAccountForPush(account.pushSubscription, true, endpoint.getUrl(), ks.getPubKey(), ks.getAuth());
} else {
// ks should never be null on new endpoint
account.getPushSubscriptionManager().registerAccountForPush(account.pushSubscription, endpoint.getUrl());
}
}
} }
@Override @Override
public void onRegistrationFailed(@NotNull Context context, @NotNull FailedReason reason, @NotNull String instance) { public void onRegistrationFailed(@NotNull Context context, @NotNull String instance) {
// called when the registration is not possible, eg. no network // called when the registration is not possible, eg. no network
Log.d(TAG, "onRegistrationFailed: " + instance); Log.d(TAG, "onRegistrationFailed: " + instance);
//re-register for gcm //re-register for gcm
AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount();
if (account != null) if (account != null)
account.getPushSubscriptionManager().registerAccountForPush(null); account.getPushSubscriptionManager().registerAccountForPush(null);
} }
@ -62,52 +47,29 @@ public class UnifiedPushNotificationReceiver extends MessagingReceiver{
// called when this application is unregistered from receiving push messages // called when this application is unregistered from receiving push messages
Log.d(TAG, "onUnregistered: " + instance); Log.d(TAG, "onUnregistered: " + instance);
//re-register for gcm //re-register for gcm
AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount();
if (account != null) if (account != null)
account.getPushSubscriptionManager().registerAccountForPush(null); account.getPushSubscriptionManager().registerAccountForPush(null);
} }
@Override @Override
public void onMessage(@NotNull Context context, @NotNull PushMessage message, @NotNull String instance) { public void onMessage(@NotNull Context context, @NotNull byte[] message, @NotNull String instance) {
Log.d(TAG, "New message for " + instance);
// Called when a new message is received. The message contains the full POST body of the push message // Called when a new message is received. The message contains the full POST body of the push message
AccountSession account = AccountSessionManager.getInstance().tryGetAccount(instance); AccountSession account = AccountSessionManager.getInstance().getAccount(instance);
if (account == null) //this is stupid
return; // Mastodon stores the info to decrypt the message in the HTTP headers, which are not accessible in UnifiedPush,
// thus it is not possible to decrypt them. SO we need to re-request them from the server and transform them later on
if (message.getDecrypted()) { // The official uses fcm and moves the headers to extra data, see
// If the mastodon server supports the standard webpush, we can directly use the content // https://github.com/mastodon/webpush-fcm-relay/blob/cac95b28d5364b0204f629283141ac3fb749e0c5/webpush-fcm-relay.go#L116
Log.d(TAG, "Push message correctly decrypted"); // https://github.com/tuskyapp/Tusky/pull/2303#issue-1112080540
PushNotification pn = MastodonAPIController.gson.fromJson(new String(message.getContent(), Charsets.UTF_8), PushNotification.class);
new GetNotificationByID(pn.notificationId)
.setCallback(new Callback<>(){
@Override
public void onSuccess(org.joinmastodon.android.model.Notification result){
MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notify(context, pn, instance, result));
}
@Override
public void onError(ErrorResponse error){
MastodonAPIController.runInBackground(()-> new PushNotificationReceiver().notify(context, pn, instance, null));
}
})
.exec(instance);
} else {
// else, we have to sync with the server
Log.d(TAG, "Server doesn't support standard webpush, fetching one notification");
fetchOneNotification(context, account, (notif) -> () -> new PushNotificationReceiver().notifyUnifiedPush(context, account, notif));
}
}
private void fetchOneNotification(@NotNull Context context, @NotNull AccountSession account, @NotNull Function<Notification, Runnable> callback) {
account.getCacheController().getNotifications(null, 1, false, false, true, new Callback<>(){ account.getCacheController().getNotifications(null, 1, false, false, true, new Callback<>(){
@Override @Override
public void onSuccess(PaginatedResponse<List<Notification>> result){ public void onSuccess(PaginatedResponse<List<Notification>> result){
result.items result.items
.stream() .stream()
.findFirst() .findFirst()
.ifPresent(value->MastodonAPIController.runInBackground(callback.apply(value))); .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value)));
} }
@Override @Override

View file

@ -9,35 +9,23 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.function.Consumer; import java.util.function.Consumer;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
@ -55,7 +43,6 @@ public class CacheController{
private final Runnable databaseCloseRunnable=this::closeDatabase; private final Runnable databaseCloseRunnable=this::closeDatabase;
private boolean loadingNotifications; private boolean loadingNotifications;
private final ArrayList<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>(); private final ArrayList<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>();
private List<FollowList> lists;
private static final int POST_FLAG_GAP_AFTER=1; private static final int POST_FLAG_GAP_AFTER=1;
@ -82,11 +69,12 @@ public class CacheController{
Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class);
status.postprocess(); status.postprocess();
int flags=cursor.getInt(1); int flags=cursor.getInt(1);
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0) ? status.id : null; status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
newMaxID=status.id; newMaxID=status.id;
result.add(status); result.add(status);
}while(cursor.moveToNext()); }while(cursor.moveToNext());
String _newMaxID=newMaxID; String _newMaxID=newMaxID;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true)));
return; return;
} }
@ -98,7 +86,9 @@ public class CacheController{
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(List<Status> result){ public void onSuccess(List<Status> result){
callback.onSuccess(new CacheablePaginatedResponse<>(result, result.isEmpty() ? null : result.get(result.size()-1).id, false)); ArrayList<Status> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME);
callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false));
putHomeTimeline(result, maxID==null); putHomeTimeline(result, maxID==null);
} }
@ -126,14 +116,12 @@ public class CacheController{
values.put("id", s.id); values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s)); values.put("json", MastodonAPIController.gson.toJson(s));
int flags=0; int flags=0;
if(Objects.equals(s.hasGapAfter, s.id)) if(s.hasGapAfter)
flags|=POST_FLAG_GAP_AFTER; flags|=POST_FLAG_GAP_AFTER;
values.put("flags", flags); values.put("flags", flags);
values.put("time", s.createdAt.getEpochSecond()); values.put("time", s.createdAt.getEpochSecond());
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
} }
if(!clear)
db.delete("home_timeline", "`id` NOT IN (SELECT `id` FROM `home_timeline` ORDER BY `time` DESC LIMIT ?)", new String[]{"1000"});
}); });
} }
@ -285,28 +273,6 @@ public class CacheController{
public void deleteStatus(String id){ public void deleteStatus(String id){
runOnDbThread((db)->{ runOnDbThread((db)->{
String gapId=null;
int gapFlags=0;
// select to-be-removed and newer row
try(Cursor cursor=db.query("home_timeline", new String[]{"id", "flags"}, "`time`>=(SELECT `time` FROM `home_timeline` WHERE `id`=?)", new String[]{id}, null, null, "`time` ASC", "2")){
boolean hadGapAfter=false;
// always either one or two iterations (only one if there's no newer post)
while(cursor.moveToNext()){
String currentId=cursor.getString(0);
int currentFlags=cursor.getInt(1);
if(currentId.equals(id)){
hadGapAfter=((currentFlags & POST_FLAG_GAP_AFTER)!=0);
}else if(hadGapAfter){
gapFlags=currentFlags|POST_FLAG_GAP_AFTER;
gapId=currentId;
}
}
}
if(gapId!=null){
ContentValues values=new ContentValues();
values.put("flags", gapFlags);
db.update("home_timeline", values, "`id`=?", new String[]{gapId});
}
db.delete("home_timeline", "`id`=?", new String[]{id}); db.delete("home_timeline", "`id`=?", new String[]{id});
}); });
} }
@ -360,99 +326,6 @@ public class CacheController{
}, 0); }, 0);
} }
public void reloadLists(Callback<List<FollowList>> callback){
new GetLists()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
result.sort(Comparator.comparing(l->l.title));
lists=result;
if(callback!=null)
callback.onSuccess(result);
writeListsToFile();
}
@Override
public void onError(ErrorResponse error){
if(callback!=null)
callback.onError(error);
}
})
.exec(accountID);
}
private List<FollowList> loadListsFromFile(){
File file=getListsFile();
if(!file.exists())
return null;
try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){
return MastodonAPIController.gson.fromJson(in, new TypeToken<List<FollowList>>(){}.getType());
}catch(Exception x){
Log.w(TAG, "failed to read lists from cache file", x);
return null;
}
}
private void writeListsToFile(){
databaseThread.postRunnable(()->{
try(OutputStreamWriter out=new OutputStreamWriter(new FileOutputStream(getListsFile()))){
MastodonAPIController.gson.toJson(lists, out);
}catch(IOException x){
Log.w(TAG, "failed to write lists to cache file", x);
}
}, 0);
}
public void getLists(Callback<List<FollowList>> callback){
if(lists!=null){
if(callback!=null)
callback.onSuccess(lists);
return;
}
databaseThread.postRunnable(()->{
List<FollowList> lists=loadListsFromFile();
if(lists!=null){
this.lists=lists;
if(callback!=null)
uiHandler.post(()->callback.onSuccess(lists));
return;
}
reloadLists(callback);
}, 0);
}
public File getListsFile(){
return new File(MastodonApp.context.getFilesDir(), "lists_"+accountID+".json");
}
public void addList(FollowList list){
if(lists==null)
return;
lists.add(list);
lists.sort(Comparator.comparing(l->l.title));
writeListsToFile();
}
public void deleteList(String id){
if(lists==null)
return;
lists.removeIf(l->l.id.equals(id));
writeListsToFile();
}
public void updateList(FollowList list){
if(lists==null)
return;
for(int i=0;i<lists.size();i++){
if(lists.get(i).id.equals(list.id)){
lists.set(i, list);
lists.sort(Comparator.comparing(l->l.title));
writeListsToFile();
break;
}
}
}
private class DatabaseHelper extends SQLiteOpenHelper{ private class DatabaseHelper extends SQLiteOpenHelper{
public DatabaseHelper(){ public DatabaseHelper(){

View file

@ -54,9 +54,7 @@ public class MastodonAPIController{
.create(); .create();
private static WorkerThread thread=new WorkerThread("MastodonAPIController"); private static WorkerThread thread=new WorkerThread("MastodonAPIController");
private static OkHttpClient httpClient=new OkHttpClient.Builder() private static OkHttpClient httpClient=new OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build(); .build();
private AccountSession session; private AccountSession session;
@ -91,11 +89,7 @@ public class MastodonAPIController{
final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h)); final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h));
thread.postRunnable(()->{ thread.postRunnable(()->{
try{ try{
if(isBad){ // if (isBad) throw new IllegalArgumentException();
Log.i(TAG, "submitRequest: refusing to connect to bad domain: " + host);
throw new IllegalArgumentException("Failed to connect to domain");
}
if(req.canceled) if(req.canceled)
return; return;
Request.Builder builder=new Request.Builder() Request.Builder builder=new Request.Builder()
@ -119,24 +113,24 @@ public class MastodonAPIController{
} }
Request hreq=builder.build(); Request hreq=builder.build();
OkHttpClient client=req.timeout>0 Call call=httpClient.newCall(hreq);
? httpClient.newBuilder().readTimeout(req.timeout, TimeUnit.MILLISECONDS).build()
: httpClient;
Call call=client.newCall(hreq);
synchronized(req){ synchronized(req){
req.okhttpCall=call; req.okhttpCall=call;
} }
if(req.timeout>0){
call.timeout().timeout(req.timeout, TimeUnit.MILLISECONDS);
}
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.d(TAG, logTag(session)+"Sending request: "+hreq); Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] Sending request: "+hreq);
call.enqueue(new Callback(){ call.enqueue(new Callback(){
@Override @Override
public void onFailure(@NonNull Call call, @NonNull IOException e){ public void onFailure(@NonNull Call call, @NonNull IOException e){
if(req.canceled) if(call.isCanceled())
return; return;
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.w(TAG, logTag(session)+""+hreq+" failed", e); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e);
synchronized(req){ synchronized(req){
req.okhttpCall=null; req.okhttpCall=null;
} }
@ -145,10 +139,10 @@ public class MastodonAPIController{
@Override @Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{
if(req.canceled) if(call.isCanceled())
return; return;
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.d(TAG, logTag(session)+hreq+" received response: "+response); Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" received response: "+response);
synchronized(req){ synchronized(req){
req.okhttpCall=null; req.okhttpCall=null;
} }
@ -159,7 +153,7 @@ public class MastodonAPIController{
try{ try{
if(BuildConfig.DEBUG){ if(BuildConfig.DEBUG){
JsonElement respJson=JsonParser.parseReader(reader); JsonElement respJson=JsonParser.parseReader(reader);
Log.d(TAG, logTag(session)+"response body: "+respJson); Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson);
if(req.respTypeToken!=null) if(req.respTypeToken!=null)
respObj=gson.fromJson(respJson, req.respTypeToken.getType()); respObj=gson.fromJson(respJson, req.respTypeToken.getType());
else if(req.respClass!=null) else if(req.respClass!=null)
@ -181,7 +175,7 @@ public class MastodonAPIController{
return; return;
} }
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.w(TAG, logTag(session)+response+" error parsing or reading body", x); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x);
req.onError(x.getLocalizedMessage(), response.code(), x); req.onError(x.getLocalizedMessage(), response.code(), x);
return; return;
} }
@ -190,19 +184,19 @@ public class MastodonAPIController{
req.validateAndPostprocessResponse(respObj, response); req.validateAndPostprocessResponse(respObj, response);
}catch(IOException x){ }catch(IOException x){
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.w(TAG, logTag(session)+response+" error post-processing or validating response", x); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
req.onError(x.getLocalizedMessage(), response.code(), x); req.onError(x.getLocalizedMessage(), response.code(), x);
return; return;
} }
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.d(TAG, logTag(session)+response+" parsed successfully: "+respObj); Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" parsed successfully: "+respObj);
req.onSuccess(respObj); req.onSuccess(respObj);
}else{ }else{
try{ try{
JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); JsonObject error=JsonParser.parseReader(reader).getAsJsonObject();
Log.w(TAG, logTag(session)+response+" received error: "+error); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error);
if(error.has("details")){ if(error.has("details")){
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null); MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null);
HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>(); HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>();
@ -226,11 +220,7 @@ public class MastodonAPIController{
}catch(JsonIOException|JsonSyntaxException x){ }catch(JsonIOException|JsonSyntaxException x){
req.onError(response.code()+" "+response.message(), response.code(), x); req.onError(response.code()+" "+response.message(), response.code(), x);
}catch(Exception x){ }catch(Exception x){
if (response.code() == 501){ req.onError("Error parsing an API error", response.code(), x);
req.onError("API route not implemented: " + response.request().url(), response.code(), x);
} else {
req.onError("Error parsing an API error", response.code(), x);
}
} }
} }
}catch(Exception x){ }catch(Exception x){
@ -241,7 +231,7 @@ public class MastodonAPIController{
}); });
}catch(Exception x){ }catch(Exception x){
if(BuildConfig.DEBUG) if(BuildConfig.DEBUG)
Log.w(TAG, logTag(session)+"error creating and sending http request", x); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x);
req.onError(x.getLocalizedMessage(), 0, x); req.onError(x.getLocalizedMessage(), 0, x);
} }
}, 0); }, 0);
@ -254,8 +244,4 @@ public class MastodonAPIController{
public static OkHttpClient getHttpClient(){ public static OkHttpClient getHttpClient(){
return httpClient; return httpClient;
} }
private static String logTag(AccountSession session){
return "["+(session==null ? "no-auth" : session.getID())+"] ";
}
} }

View file

@ -37,8 +37,6 @@ import okhttp3.Response;
public abstract class MastodonAPIRequest<T> extends APIRequest<T>{ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
private static final String TAG="MastodonAPIRequest"; private static final String TAG="MastodonAPIRequest";
private static MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null);
private String domain; private String domain;
private AccountSession account; private AccountSession account;
private String path; private String path;
@ -97,14 +95,14 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
public MastodonAPIRequest<T> execNoAuth(String domain){ public MastodonAPIRequest<T> execNoAuth(String domain){
this.domain=domain; this.domain=domain;
unauthenticatedApiController.submitRequest(this); AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this);
return this; return this;
} }
public MastodonAPIRequest<T> exec(String domain, Token token){ public MastodonAPIRequest<T> exec(String domain, Token token){
this.domain=domain; this.domain=domain;
this.token=token; this.token=token;
unauthenticatedApiController.submitRequest(this); AccountSessionManager.getInstance().getUnauthenticatedApiController().submitRequest(this);
return this; return this;
} }
@ -139,7 +137,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
return this; return this;
} }
public void setRequestBody(Object body){ protected void setRequestBody(Object body){
requestBody=body; requestBody=body;
} }
@ -155,9 +153,8 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
headers.put(key, value); headers.put(key, value);
} }
public MastodonAPIRequest<T> setTimeout(long timeout){ protected void setTimeout(long timeout){
this.timeout=timeout; this.timeout=timeout;
return this;
} }
protected String getPathPrefix(){ protected String getPathPrefix(){
@ -182,8 +179,6 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
} }
public RequestBody getRequestBody() throws IOException{ public RequestBody getRequestBody() throws IOException{
if(requestBody instanceof RequestBody rb)
return rb;
return requestBody==null ? null : new JsonObjectRequestBody(requestBody); return requestBody==null ? null : new JsonObjectRequestBody(requestBody);
} }

View file

@ -125,16 +125,17 @@ public class PushSubscriptionManager{
// this function is used for registering push notifications using FCM // this function is used for registering push notifications using FCM
// to avoid NonFreeNet in F-Droid, this registration is disabled in it // to avoid NonFreeNet in F-Droid, this registration is disabled in it
// see https://github.com/LucasGGamerM/moshidon/issues/206 for more context // see https://github.com/LucasGGamerM/moshidon/issues/206 for more context
if(BuildConfig.BUILD_TYPE.equals("fdroidRelease") || TextUtils.isEmpty(deviceToken)){ if(BuildConfig.BUILD_TYPE.equals("fdroidRelease"))
Log.d(TAG, "Skipping registering for FCM push notifications");
return; return;
}
String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"; if(TextUtils.isEmpty(deviceToken))
throw new IllegalStateException("No device push token available");
String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID;
registerAccountForPush(subscription, endpoint); registerAccountForPush(subscription, endpoint);
} }
public void registerAccountForPush(PushSubscription subscription, String endpoint){ public void registerAccountForPush(PushSubscription subscription, String endpoint){
MastodonAPIController.runInBackground(()->{ MastodonAPIController.runInBackground(()->{
Log.d(TAG, "registerAccountForPush: started for "+accountID); Log.d(TAG, "registerAccountForPush: started for "+accountID);
String encodedPublicKey, encodedAuthKey, pushAccountID; String encodedPublicKey, encodedAuthKey, pushAccountID;
@ -163,26 +164,9 @@ public class PushSubscriptionManager{
Log.e(TAG, "registerAccountForPush: error generating encryption key", e); Log.e(TAG, "registerAccountForPush: error generating encryption key", e);
return; return;
} }
//work-around for adding the randomAccountId
String newEndpoint = endpoint;
Boolean standard = true;
if (endpoint.startsWith("https://app.joinmastodon.org/relay-to/fcm/")){
newEndpoint+=pushAccountID;
standard = false;
}
registerAccountForPush(subscription, standard, newEndpoint, encodedPublicKey, encodedAuthKey);
});
}
public void registerAccountForPush(PushSubscription subscription, Boolean standard, String endpoint, String p256dh, String auth){
MastodonAPIController.runInBackground(()->{
Log.d(TAG, "registerAccountForPush: started for "+accountID);
new RegisterForPushNotifications(endpoint, new RegisterForPushNotifications(endpoint,
standard, encodedPublicKey,
p256dh, encodedAuthKey,
auth,
subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts, subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts,
subscription==null ? PushSubscription.Policy.ALL : subscription.policy) subscription==null ? PushSubscription.Policy.ALL : subscription.policy)
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){

View file

@ -6,25 +6,12 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked;
import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited;
import org.joinmastodon.android.api.requests.statuses.SetStatusMuted;
import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent;
import org.joinmastodon.android.events.ReblogDeletedEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.EmojiReaction;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
@ -36,7 +23,6 @@ public class StatusInteractionController{
private final HashMap<String, SetStatusFavorited> runningFavoriteRequests=new HashMap<>(); private final HashMap<String, SetStatusFavorited> runningFavoriteRequests=new HashMap<>();
private final HashMap<String, SetStatusReblogged> runningReblogRequests=new HashMap<>(); private final HashMap<String, SetStatusReblogged> runningReblogRequests=new HashMap<>();
private final HashMap<String, SetStatusBookmarked> runningBookmarkRequests=new HashMap<>(); private final HashMap<String, SetStatusBookmarked> runningBookmarkRequests=new HashMap<>();
private final HashMap<String, SetStatusMuted> runningMuteRequests=new HashMap<>();
public StatusInteractionController(String accountID, boolean updateCounters) { public StatusInteractionController(String accountID, boolean updateCounters) {
this.accountID=accountID; this.accountID=accountID;
@ -51,9 +37,6 @@ public class StatusInteractionController{
if(!Looper.getMainLooper().isCurrentThread()) if(!Looper.getMainLooper().isCurrentThread())
throw new IllegalStateException("Can only be called from main thread"); throw new IllegalStateException("Can only be called from main thread");
AccountSession session=AccountSessionManager.get(accountID);
Instance instance=session.getInstance().get();
SetStatusFavorited current=runningFavoriteRequests.remove(status.id); SetStatusFavorited current=runningFavoriteRequests.remove(status.id);
if(current!=null){ if(current!=null){
current.cancel(); current.cancel();
@ -65,8 +48,7 @@ public class StatusInteractionController{
runningFavoriteRequests.remove(status.id); runningFavoriteRequests.remove(status.id);
result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1)); result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1));
cb.accept(result); cb.accept(result);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(result)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result));
if(instance.isIceshrimpJs()) E.post(new EmojiReactionsUpdatedEvent(status.id, result.reactions, false, null));
} }
@Override @Override
@ -75,59 +57,13 @@ public class StatusInteractionController{
error.showToast(MastodonApp.context); error.showToast(MastodonApp.context);
status.favourited=!favorited; status.favourited=!favorited;
cb.accept(status); cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
if(instance.isIceshrimpJs()) E.post(new EmojiReactionsUpdatedEvent(status.id, status.reactions, false, null));
} }
}) })
.exec(accountID); .exec(accountID);
runningFavoriteRequests.put(status.id, req); runningFavoriteRequests.put(status.id, req);
status.favourited=favorited; status.favourited=favorited;
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
if(instance.configuration==null || instance.configuration.reactions==null)
return;
String defaultReactionEmojiRaw=instance.configuration.reactions.defaultReaction;
if(!instance.isIceshrimpJs() || defaultReactionEmojiRaw==null)
return;
boolean reactionIsCustom=defaultReactionEmojiRaw.startsWith(":");
String defaultReactionEmoji=reactionIsCustom ? defaultReactionEmojiRaw.substring(1, defaultReactionEmojiRaw.length()-1) : defaultReactionEmojiRaw;
ArrayList<EmojiReaction> reactions=new ArrayList<>(status.reactions.size());
for(EmojiReaction reaction:status.reactions){
reactions.add(reaction.copy());
}
Optional<EmojiReaction> existingReaction=reactions.stream().filter(r->r.me).findFirst();
Optional<EmojiReaction> existingDefaultReaction=reactions.stream().filter(r->r.name.equals(defaultReactionEmoji)).findFirst();
if(existingReaction.isPresent() && !favorited){
existingReaction.get().me=false;
existingReaction.get().count--;
existingReaction.get().pendingChange=true;
}else if(existingDefaultReaction.isPresent() && favorited){
existingDefaultReaction.get().count++;
existingDefaultReaction.get().me=true;
existingDefaultReaction.get().pendingChange=true;
}else if(favorited){
EmojiReaction reaction=null;
if(reactionIsCustom){
List<EmojiCategory> customEmojis=AccountSessionManager.getInstance().getCustomEmojis(session.domain);
for(EmojiCategory category:customEmojis){
for(Emoji emoji:category.emojis){
if(emoji.shortcode.equals(defaultReactionEmoji)){
reaction=EmojiReaction.of(emoji, session.self);
break;
}
}
}
if(reaction==null)
reaction=EmojiReaction.of(defaultReactionEmoji, session.self);
}else{
reaction=EmojiReaction.of(defaultReactionEmoji, session.self);
}
reaction.pendingChange=true;
reactions.add(reaction);
}
E.post(new EmojiReactionsUpdatedEvent(status.id, reactions, false, null));
} }
public void setReblogged(Status status, boolean reblogged, StatusPrivacy visibility, Consumer<Status> cb){ public void setReblogged(Status status, boolean reblogged, StatusPrivacy visibility, Consumer<Status> cb){
@ -142,15 +78,11 @@ public class StatusInteractionController{
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(Status reblog){ public void onSuccess(Status reblog){
Status result=reblog.getContentStatus(); Status result = reblog.getContentStatus();
runningReblogRequests.remove(status.id); runningReblogRequests.remove(status.id);
result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1)); result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1));
cb.accept(result); cb.accept(result);
if(updateCounters){ if (updateCounters) E.post(new StatusCountersUpdatedEvent(result));
E.post(new StatusCountersUpdatedEvent(result));
if(reblogged) E.post(new StatusCreatedEvent(reblog, accountID));
else E.post(new ReblogDeletedEvent(status.id, accountID));
}
} }
@Override @Override
@ -159,13 +91,13 @@ public class StatusInteractionController{
error.showToast(MastodonApp.context); error.showToast(MastodonApp.context);
status.reblogged=!reblogged; status.reblogged=!reblogged;
cb.accept(status); cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
} }
}) })
.exec(accountID); .exec(accountID);
runningReblogRequests.put(status.id, req); runningReblogRequests.put(status.id, req);
status.reblogged=reblogged; status.reblogged=reblogged;
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
} }
public void setBookmarked(Status status, boolean bookmarked){ public void setBookmarked(Status status, boolean bookmarked){
@ -186,7 +118,7 @@ public class StatusInteractionController{
public void onSuccess(Status result){ public void onSuccess(Status result){
runningBookmarkRequests.remove(status.id); runningBookmarkRequests.remove(status.id);
cb.accept(result); cb.accept(result);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(result)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result));
} }
@Override @Override
@ -195,12 +127,12 @@ public class StatusInteractionController{
error.showToast(MastodonApp.context); error.showToast(MastodonApp.context);
status.bookmarked=!bookmarked; status.bookmarked=!bookmarked;
cb.accept(status); cb.accept(status);
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
} }
}) })
.exec(accountID); .exec(accountID);
runningBookmarkRequests.put(status.id, req); runningBookmarkRequests.put(status.id, req);
status.bookmarked=bookmarked; status.bookmarked=bookmarked;
if(updateCounters) E.post(new StatusCountersUpdatedEvent(status)); if (updateCounters) E.post(new StatusCountersUpdatedEvent(status));
} }
} }

View file

@ -1,14 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class BiteAccount extends MastodonAPIRequest{
public BiteAccount(String id){
super(HttpMethod.POST, "/users/"+id+"/bite", BiteAccount.class);
}
}

View file

@ -1,22 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.model.BaseModel;
public class CheckInviteLink extends MastodonAPIRequest<CheckInviteLink.Response>{
public CheckInviteLink(String path){
super(HttpMethod.GET, path, Response.class);
addHeader("Accept", "application/json");
}
@Override
protected String getPathPrefix(){
return "";
}
public static class Response extends BaseModel{
@RequiredField
public String inviteCode;
}
}

View file

@ -1,16 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountBlocks extends HeaderPaginationRequest<Account>{
public GetAccountBlocks(String maxID, int limit){
super(HttpMethod.GET, "/blocks", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View file

@ -1,14 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class GetAccountLists extends MastodonAPIRequest<List<FollowList>>{
public GetAccountLists(String id){
super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){});
}
}

View file

@ -1,16 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountMutes extends HeaderPaginationRequest<Account>{
public GetAccountMutes(String maxID, int limit){
super(HttpMethod.GET, "/mutes/", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View file

@ -4,23 +4,22 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Token; import org.joinmastodon.android.model.Token;
public class RegisterAccount extends MastodonAPIRequest<Token>{ public class RegisterAccount extends MastodonAPIRequest<Token>{
public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone, String inviteCode){ public RegisterAccount(String username, String email, String password, String locale, String reason, String timezone){
super(HttpMethod.POST, "/accounts", Token.class); super(HttpMethod.POST, "/accounts", Token.class);
setRequestBody(new Body(username, email, password, locale, reason, timezone, inviteCode)); setRequestBody(new Body(username, email, password, locale, reason, timezone));
} }
private static class Body{ private static class Body{
public String username, email, password, locale, reason, timeZone, inviteCode; public String username, email, password, locale, reason, timeZone;
public boolean agreement=true; public boolean agreement=true;
public Body(String username, String email, String password, String locale, String reason, String timeZone, String inviteCode){ public Body(String username, String email, String password, String locale, String reason, String timeZone){
this.username=username; this.username=username;
this.email=email; this.email=email;
this.password=password; this.password=password;
this.locale=locale; this.locale=locale;
this.reason=reason; this.reason=reason;
this.timeZone=timeZone; this.timeZone=timeZone;
this.inviteCode=inviteCode;
} }
} }
} }

View file

@ -1,23 +0,0 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Account;
import java.util.List;
public class SearchAccounts extends MastodonAPIRequest<List<Account>>{
public SearchAccounts(String q, int limit, int offset, boolean resolve, boolean following){
super(HttpMethod.GET, "/accounts/search", new TypeToken<>(){});
addQueryParameter("q", q);
if(limit>0)
addQueryParameter("limit", limit+"");
if(offset>0)
addQueryParameter("offset", offset+"");
if(resolve)
addQueryParameter("resolve", "true");
if(following)
addQueryParameter("following", "true");
}
}

View file

@ -4,21 +4,15 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Relationship;
public class SetAccountMuted extends MastodonAPIRequest<Relationship>{ public class SetAccountMuted extends MastodonAPIRequest<Relationship>{
public SetAccountMuted(String id, boolean muted, long duration, boolean muteNotifications){ public SetAccountMuted(String id, boolean muted, long duration){
super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class); super(HttpMethod.POST, "/accounts/"+id+"/"+(muted ? "mute" : "unmute"), Relationship.class);
if(muted) setRequestBody(new Request(duration));
setRequestBody(new Request(duration, muteNotifications));
else{
setRequestBody(new Object());
}
} }
private static class Request{ private static class Request{
public long duration; public long duration;
public boolean muteNotifications; public Request(long duration){
public Request(long duration, boolean muteNotifications){
this.duration=duration; this.duration=duration;
this.muteNotifications=muteNotifications;
} }
} }
} }

View file

@ -22,7 +22,6 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
private Uri avatar, cover; private Uri avatar, cover;
private File avatarFile, coverFile; private File avatarFile, coverFile;
private List<AccountField> fields; private List<AccountField> fields;
private Boolean discoverable, indexable;
public UpdateAccountCredentials(String displayName, String bio, Uri avatar, Uri cover, List<AccountField> fields){ public UpdateAccountCredentials(String displayName, String bio, Uri avatar, Uri cover, List<AccountField> fields){
super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class);
@ -42,12 +41,6 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
this.fields=fields; this.fields=fields;
} }
public UpdateAccountCredentials setDiscoverableIndexable(boolean discoverable, boolean indexable){
this.discoverable=discoverable;
this.indexable=indexable;
return this;
}
@Override @Override
public RequestBody getRequestBody() throws IOException{ public RequestBody getRequestBody() throws IOException{
MultipartBody.Builder bldr=new MultipartBody.Builder() MultipartBody.Builder bldr=new MultipartBody.Builder()
@ -65,21 +58,15 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
}else if(coverFile!=null){ }else if(coverFile!=null){
bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null)); bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null));
} }
if(fields!=null){ if(fields.isEmpty()){
if(fields.isEmpty()){ bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", "");
bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", ""); }else{
}else{ int i=0;
int i=0; for(AccountField field:fields){
for(AccountField field:fields){ bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value);
bldr.addFormDataPart("fields_attributes["+i+"][name]", field.name).addFormDataPart("fields_attributes["+i+"][value]", field.value); i++;
i++;
}
} }
} }
if(discoverable!=null)
bldr.addFormDataPart("discoverable", discoverable.toString());
if(indexable!=null)
bldr.addFormDataPart("indexable", indexable.toString());
return bldr.build(); return bldr.build();
} }

View file

@ -6,19 +6,18 @@ import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
public class UpdateAccountCredentialsPreferences extends MastodonAPIRequest<Account>{ public class UpdateAccountCredentialsPreferences extends MastodonAPIRequest<Account>{
public UpdateAccountCredentialsPreferences(Preferences preferences, Boolean locked, Boolean discoverable, Boolean indexable){ public UpdateAccountCredentialsPreferences(Preferences preferences, Boolean locked, Boolean discoverable){
super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class); super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class);
setRequestBody(new Request(locked, discoverable, indexable, new RequestSource(preferences.postingDefaultVisibility, preferences.postingDefaultLanguage))); setRequestBody(new Request(locked, discoverable, new RequestSource(preferences.postingDefaultVisibility, preferences.postingDefaultLanguage)));
} }
private static class Request{ private static class Request{
public Boolean locked, discoverable, indexable; public Boolean locked, discoverable;
public RequestSource source; public RequestSource source;
public Request(Boolean locked, Boolean discoverable, Boolean indexable, RequestSource source){ public Request(Boolean locked, Boolean discoverable, RequestSource source){
this.locked=locked; this.locked=locked;
this.discoverable=discoverable; this.discoverable=discoverable;
this.indexable=indexable;
this.source=source; this.source=source;
} }
} }

View file

@ -6,9 +6,6 @@ import org.joinmastodon.android.model.FilterContext;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import androidx.annotation.Keep;
@Keep
class FilterRequest{ class FilterRequest{
public String title; public String title;
public EnumSet<FilterContext> context; public EnumSet<FilterContext> context;

View file

@ -2,9 +2,6 @@ package org.joinmastodon.android.api.requests.filters;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import androidx.annotation.Keep;
@Keep
class KeywordAttribute{ class KeywordAttribute{
public String id; public String id;
@SerializedName("_destroy") @SerializedName("_destroy")

View file

@ -1,19 +1,17 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import java.util.List;
import java.nio.charset.StandardCharsets; public class AddAccountsToList extends MastodonAPIRequest<Object> {
import java.util.Collection; public AddAccountsToList(String listId, List<String> accountIds){
super(HttpMethod.POST, "/lists/"+listId+"/accounts", Object.class);
Request req = new Request();
req.accountIds = accountIds;
setRequestBody(req);
}
import okhttp3.FormBody; public static class Request{
public List<String> accountIds;
public class AddAccountsToList extends ResultlessMastodonAPIRequest{ }
public AddAccountsToList(String listID, Collection<String> accountIDs){
super(HttpMethod.POST, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
} }

View file

@ -1,23 +1,21 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.ListTimeline;
public class CreateList extends MastodonAPIRequest<FollowList>{ public class CreateList extends MastodonAPIRequest<ListTimeline> {
public CreateList(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ public CreateList(String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) {
super(HttpMethod.POST, "/lists", FollowList.class); super(HttpMethod.POST, "/lists", ListTimeline.class);
setRequestBody(new Request(title, repliesPolicy, exclusive)); Request req = new Request();
req.title = title;
req.exclusive = exclusive;
req.repliesPolicy = repliesPolicy;
setRequestBody(req);
} }
private static class Request{ public static class Request {
public String title; public String title;
public FollowList.RepliesPolicy repliesPolicy;
public boolean exclusive; public boolean exclusive;
public ListTimeline.RepliesPolicy repliesPolicy;
public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
this.title=title;
this.repliesPolicy=repliesPolicy;
this.exclusive=exclusive;
}
} }
} }

View file

@ -1,9 +1,10 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.ListTimeline;
public class DeleteList extends ResultlessMastodonAPIRequest{ public class DeleteList extends MastodonAPIRequest<Object> {
public DeleteList(String id){ public DeleteList(String id) {
super(HttpMethod.DELETE, "/lists/"+id); super(HttpMethod.DELETE, "/lists/" + id, Object.class);
} }
} }

View file

@ -1,10 +1,10 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.ListTimeline;
public class GetList extends MastodonAPIRequest<FollowList> { public class GetList extends MastodonAPIRequest<ListTimeline> {
public GetList(String id) { public GetList(String id) {
super(HttpMethod.GET, "/lists/" + id, FollowList.class); super(HttpMethod.GET, "/lists/" + id, ListTimeline.class);
} }
} }

View file

@ -1,17 +0,0 @@
package org.joinmastodon.android.api.requests.lists;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetListAccounts extends HeaderPaginationRequest<Account>{
public GetListAccounts(String listID, String maxID, int limit){
super(HttpMethod.GET, "/lists/"+listID+"/accounts", new TypeToken<>(){});
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
addQueryParameter("limit", String.valueOf(limit));
}
}

View file

@ -3,11 +3,11 @@ package org.joinmastodon.android.api.requests.lists;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.ListTimeline;
import java.util.List; import java.util.List;
public class GetLists extends MastodonAPIRequest<List<FollowList>>{ public class GetLists extends MastodonAPIRequest<List<ListTimeline>>{
public GetLists() { public GetLists() {
super(HttpMethod.GET, "/lists", new TypeToken<>(){}); super(HttpMethod.GET, "/lists", new TypeToken<>(){});
} }

View file

@ -1,19 +1,17 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import java.util.List;
import java.nio.charset.StandardCharsets; public class RemoveAccountsFromList extends MastodonAPIRequest<Object> {
import java.util.Collection; public RemoveAccountsFromList(String listId, List<String> accountIds){
super(HttpMethod.DELETE, "/lists/"+listId+"/accounts", Object.class);
Request req = new Request();
req.accountIds = accountIds;
setRequestBody(req);
}
import okhttp3.FormBody; public static class Request{
public List<String> accountIds;
public class RemoveAccountsFromList extends ResultlessMastodonAPIRequest{ }
public RemoveAccountsFromList(String listID, Collection<String> accountIDs){
super(HttpMethod.DELETE, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
} }

View file

@ -1,23 +1,15 @@
package org.joinmastodon.android.api.requests.lists; package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.ListTimeline;
public class UpdateList extends MastodonAPIRequest<FollowList>{ public class UpdateList extends MastodonAPIRequest<ListTimeline> {
public UpdateList(String listID, String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){ public UpdateList(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) {
super(HttpMethod.PUT, "/lists/"+listID, FollowList.class); super(HttpMethod.PUT, "/lists/" + id, ListTimeline.class);
setRequestBody(new Request(title, repliesPolicy, exclusive)); CreateList.Request req = new CreateList.Request();
} req.title = title;
req.exclusive = exclusive;
private static class Request{ req.repliesPolicy = repliesPolicy;
public String title; setRequestBody(req);
public FollowList.RepliesPolicy repliesPolicy;
public boolean exclusive;
public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
this.title=title;
this.repliesPolicy=repliesPolicy;
this.exclusive=exclusive;
}
} }
} }

View file

@ -13,7 +13,7 @@ import okhttp3.MultipartBody;
import okhttp3.RequestBody; import okhttp3.RequestBody;
public class PleromaMarkNotificationsRead extends MastodonAPIRequest<List<Notification>> { public class PleromaMarkNotificationsRead extends MastodonAPIRequest<List<Notification>> {
private final String maxID; private String maxID;
public PleromaMarkNotificationsRead(String maxID) { public PleromaMarkNotificationsRead(String maxID) {
super(HttpMethod.POST, "/pleroma/notifications/read", new TypeToken<>(){}); super(HttpMethod.POST, "/pleroma/notifications/read", new TypeToken<>(){});
this.maxID = maxID; this.maxID = maxID;

View file

@ -4,11 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.PushSubscription;
public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscription>{ public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscription>{
public RegisterForPushNotifications(String endpoint, Boolean standard, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy){ public RegisterForPushNotifications(String endpoint, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy){
super(HttpMethod.POST, "/push/subscription", PushSubscription.class); super(HttpMethod.POST, "/push/subscription", PushSubscription.class);
Request r=new Request(); Request r=new Request();
r.subscription.endpoint=endpoint; r.subscription.endpoint=endpoint;
r.subscription.standard = standard;
r.data.alerts=alerts; r.data.alerts=alerts;
r.policy=policy; r.policy=policy;
r.subscription.keys.p256dh=encryptionKey; r.subscription.keys.p256dh=encryptionKey;
@ -28,8 +27,6 @@ public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscri
private static class Subscription{ private static class Subscription{
public String endpoint; public String endpoint;
// Use standard push notifications if available
public Boolean standard;
public Keys keys=new Keys(); public Keys keys=new Keys();
} }

View file

@ -4,19 +4,13 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.SearchResults;
public class GetSearchResults extends MastodonAPIRequest<SearchResults>{ public class GetSearchResults extends MastodonAPIRequest<SearchResults>{
public GetSearchResults(String query, Type type, boolean resolve, String maxID, int offset, int count){ public GetSearchResults(String query, Type type, boolean resolve){
super(HttpMethod.GET, "/search", SearchResults.class); super(HttpMethod.GET, "/search", SearchResults.class);
addQueryParameter("q", query); addQueryParameter("q", query);
if(type!=null) if(type!=null)
addQueryParameter("type", type.name().toLowerCase()); addQueryParameter("type", type.name().toLowerCase());
if(resolve) if(resolve)
addQueryParameter("resolve", "true"); addQueryParameter("resolve", "true");
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(offset>0)
addQueryParameter("offset", String.valueOf(offset));
if(count>0)
addQueryParameter("limit", String.valueOf(count));
} }
public GetSearchResults limit(int limit){ public GetSearchResults limit(int limit){

View file

@ -1,10 +0,0 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.AkkomaTranslation;
public class AkkomaTranslateStatus extends MastodonAPIRequest<AkkomaTranslation>{
public AkkomaTranslateStatus(String id, String lang){
super(HttpMethod.GET, "/statuses/"+id+"/translations/"+lang.toLowerCase(), AkkomaTranslation.class);
}
}

View file

@ -1,9 +0,0 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
public class BitePost extends MastodonAPIRequest{
public BitePost(String id){
super(HttpMethod.POST, "/statuses/"+id+"/bite", BitePost.class);
}
}

View file

@ -11,11 +11,13 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public class CreateStatus extends MastodonAPIRequest<Status>{ public class CreateStatus extends MastodonAPIRequest<Status>{
public static long EPOCH_OF_THE_YEAR_FIVE_THOUSAND=95617584000000L; public static final Instant DRAFTS_AFTER_INSTANT = Instant.ofEpochMilli(253370764799999L) /* end of 9998 */;
public static final Instant DRAFTS_AFTER_INSTANT=Instant.ofEpochMilli(EPOCH_OF_THE_YEAR_FIVE_THOUSAND - 1) /* end of 4999 */; private static final float draftFactor = 31536000000f /* one year */ / 253370764799999f /* end of 9998 */;
public static Instant getDraftInstant() { public static Instant getDraftInstant() {
return DRAFTS_AFTER_INSTANT.plusMillis(System.currentTimeMillis()); // returns an instant between 9999-01-01 00:00:00 and 9999-12-31 23:59:59
// yes, this is a weird implementation for something that hardly matters
return DRAFTS_AFTER_INSTANT.plusMillis(1 + (long) (System.currentTimeMillis() * draftFactor));
} }
public CreateStatus(CreateStatus.Request req, String uuid){ public CreateStatus(CreateStatus.Request req, String uuid){
@ -34,7 +36,6 @@ public class CreateStatus extends MastodonAPIRequest<Status>{
public static class Request{ public static class Request{
public String status; public String status;
public List<MediaAttribute> mediaAttributes;
public List<String> mediaIds; public List<String> mediaIds;
public Poll poll; public Poll poll;
public String inReplyToId; public String inReplyToId;
@ -48,25 +49,11 @@ public class CreateStatus extends MastodonAPIRequest<Status>{
public String quoteId; public String quoteId;
public ContentType contentType; public ContentType contentType;
public boolean preview;
public static class Poll{ public static class Poll{
public ArrayList<String> options=new ArrayList<>(); public ArrayList<String> options=new ArrayList<>();
public int expiresIn; public int expiresIn;
public boolean multiple; public boolean multiple;
public boolean hideTotals; public boolean hideTotals;
} }
public static class MediaAttribute{
public String id;
public String description;
public String focus;
public MediaAttribute(String id, String description, String focus){
this.id=id;
this.description=description;
this.focus=focus;
}
}
} }
} }

View file

@ -26,11 +26,6 @@ public class GetStatusEditHistory extends MastodonAPIRequest<List<Status>>{
s.visibility=StatusPrivacy.PUBLIC; s.visibility=StatusPrivacy.PUBLIC;
s.mentions=Collections.emptyList(); s.mentions=Collections.emptyList();
s.tags=Collections.emptyList(); s.tags=Collections.emptyList();
if(s.poll!=null){
s.poll.id="fakeID"+i;
s.poll.emojis=Collections.emptyList();
s.poll.ownVotes=Collections.emptyList();
}
i++; i++;
} }
super.validateAndPostprocessResponse(respObj, httpResponse); super.validateAndPostprocessResponse(respObj, httpResponse);

View file

@ -1,11 +0,0 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
public class SetStatusMuted extends MastodonAPIRequest<Status>{
public SetStatusMuted(String id, boolean muted){
super(HttpMethod.POST, "/statuses/"+id+"/"+(muted ? "mute" : "unmute"), Status.class);
setRequestBody(new Object());
}
}

View file

@ -1,13 +1,11 @@
package org.joinmastodon.android.api.requests.statuses; package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Translation; import org.joinmastodon.android.model.TranslatedStatus;
import java.util.Map; public class TranslateStatus extends MastodonAPIRequest<TranslatedStatus> {
public TranslateStatus(String id) {
public class TranslateStatus extends MastodonAPIRequest<Translation>{ super(HttpMethod.POST, "/statuses/"+id+"/translate", TranslatedStatus.class);
public TranslateStatus(String id, String lang){ setRequestBody(new Object());
super(HttpMethod.POST, "/statuses/"+id+"/translate", Translation.class); }
setRequestBody(Map.of("lang", lang));
}
} }

View file

@ -1,16 +0,0 @@
package org.joinmastodon.android.api.requests.tags;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Hashtag;
public class GetFollowedTags extends HeaderPaginationRequest<Hashtag>{
public GetFollowedTags(String maxID, int limit){
super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View file

@ -1,10 +0,0 @@
package org.joinmastodon.android.api.requests.tags;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Hashtag;
public class GetTag extends MastodonAPIRequest<Hashtag>{
public GetTag(String tag){
super(HttpMethod.GET, "/tags/"+tag, Hashtag.class);
}
}

View file

@ -1,11 +0,0 @@
package org.joinmastodon.android.api.requests.tags;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Hashtag;
public class SetTagFollowed extends MastodonAPIRequest<Hashtag>{
public SetTagFollowed(String tag, boolean followed){
super(HttpMethod.POST, "/tags/"+tag+(followed ? "/follow" : "/unfollow"), Hashtag.class);
setRequestBody(new Object());
}
}

View file

@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status;
import java.util.List; import java.util.List;
public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{ public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID, String replyVisibility){ public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit, String replyVisibility){
super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){}); super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){});
if(local) if(local)
addQueryParameter("local", "true"); addQueryParameter("local", "true");
@ -18,10 +18,6 @@ public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
addQueryParameter("remote", "true"); addQueryParameter("remote", "true");
if(!TextUtils.isEmpty(maxID)) if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID); addQueryParameter("max_id", maxID);
if(!TextUtils.isEmpty(minID))
addQueryParameter("min_id", minID);
if(!TextUtils.isEmpty(sinceID))
addQueryParameter("since_id", sinceID);
if(limit>0) if(limit>0)
addQueryParameter("limit", limit+""); addQueryParameter("limit", limit+"");
if(replyVisibility != null) if(replyVisibility != null)

View file

@ -1,23 +0,0 @@
package org.joinmastodon.android.api.requests.timelines;
import androidx.annotation.NonNull;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetTrendingLinksTimeline extends MastodonAPIRequest<List<Status>>{
public GetTrendingLinksTimeline(@NonNull String url, String maxID, String minID, int limit){
super(HttpMethod.GET, "/timelines/link/", new TypeToken<>(){});
addQueryParameter("url", url);
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}
}

View file

@ -6,17 +6,10 @@ import static org.joinmastodon.android.api.MastodonAPIController.gson;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import androidx.annotation.StringRes;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.model.TimelineDefinition;
import java.lang.reflect.Type; import java.lang.reflect.Type;
@ -24,7 +17,6 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
public class AccountLocalPreferences{ public class AccountLocalPreferences{
private final SharedPreferences prefs; private final SharedPreferences prefs;
@ -48,21 +40,16 @@ public class AccountLocalPreferences{
public String publishButtonText; public String publishButtonText;
public String timelineReplyVisibility; // akkoma-only public String timelineReplyVisibility; // akkoma-only
public boolean keepOnlyLatestNotification; public boolean keepOnlyLatestNotification;
public boolean emojiReactionsEnabled;
public ShowEmojiReactions showEmojiReactions;
public ColorPreference color;
public ArrayList<Emoji> recentCustomEmoji;
public boolean preReplySheet;
private final static Type recentLanguagesType=new TypeToken<ArrayList<String>>() {}.getType(); public boolean emojiReactionsEnabled;
private final static Type timelinesType=new TypeToken<ArrayList<TimelineDefinition>>() {}.getType(); public boolean showEmojiReactionsInLists;
private final static Type recentCustomEmojiType=new TypeToken<ArrayList<Emoji>>() {}.getType();
private final static Type recentLanguagesType = new TypeToken<ArrayList<String>>() {}.getType();
private final static Type timelinesType = new TypeToken<ArrayList<TimelineDefinition>>() {}.getType();
// MOSHIDON // MOSHIDON
// private final static Type recentEmojisType = new TypeToken<Map<String, Integer>>() {}.getType(); private final static Type recentEmojisType = new TypeToken<Map<String, Integer>>() {}.getType();
// public Map<String, Integer> recentEmojis; public Map<String, Integer> recentEmojis;
private final static Type notificationFiltersType = new TypeToken<PushSubscription.Alerts>() {}.getType();
public PushSubscription.Alerts notificationFilters;
public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){
this.prefs=prefs; this.prefs=prefs;
@ -71,30 +58,25 @@ public class AccountLocalPreferences{
revealCWs=prefs.getBoolean("revealCWs", false); revealCWs=prefs.getBoolean("revealCWs", false);
hideSensitiveMedia=prefs.getBoolean("hideSensitive", true); hideSensitiveMedia=prefs.getBoolean("hideSensitive", true);
serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false); serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false);
// preReplySheet=prefs.getBoolean("preReplySheet", false);
// MEGALODON // MEGALODON
Optional<Instance> instance=session.getInstance();
showReplies=prefs.getBoolean("showReplies", true); showReplies=prefs.getBoolean("showReplies", true);
showBoosts=prefs.getBoolean("showBoosts", true); showBoosts=prefs.getBoolean("showBoosts", true);
recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new ArrayList<>()); recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new ArrayList<>());
bottomEncoding=prefs.getBoolean("bottomEncoding", false); bottomEncoding=prefs.getBoolean("bottomEncoding", false);
defaultContentType=enumValue(ContentType.class, prefs.getString("defaultContentType", instance.map(Instance::isIceshrimp).orElse(false) ? ContentType.MISSKEY_MARKDOWN.name() : ContentType.PLAIN.name())); defaultContentType=enumValue(ContentType.class, prefs.getString("defaultContentType", ContentType.PLAIN.name()));
contentTypesEnabled=prefs.getBoolean("contentTypesEnabled", instance.map(i->!i.isIceshrimp()).orElse(false)); contentTypesEnabled=prefs.getBoolean("contentTypesEnabled", true);
timelines=fromJson(prefs.getString("timelines", null), timelinesType, TimelineDefinition.getDefaultTimelines(session.getID())); timelines=fromJson(prefs.getString("timelines", null), timelinesType, TimelineDefinition.getDefaultTimelines(session.getID()));
localOnlySupported=prefs.getBoolean("localOnlySupported", false); localOnlySupported=prefs.getBoolean("localOnlySupported", false);
glitchInstance=prefs.getBoolean("glitchInstance", false); glitchInstance=prefs.getBoolean("glitchInstance", false);
publishButtonText=prefs.getString("publishButtonText", null); publishButtonText=prefs.getString("publishButtonText", null);
timelineReplyVisibility=prefs.getString("timelineReplyVisibility", null); timelineReplyVisibility=prefs.getString("timelineReplyVisibility", null);
keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false); keepOnlyLatestNotification=prefs.getBoolean("keepOnlyLatestNotification", false);
emojiReactionsEnabled=prefs.getBoolean("emojiReactionsEnabled", instance.map(i->i.isAkkoma() || i.isIceshrimp()).orElse(false)); emojiReactionsEnabled=prefs.getBoolean("emojiReactionsEnabled", session.getInstance().isPresent() && session.getInstance().get().isAkkoma());
showEmojiReactions=ShowEmojiReactions.valueOf(prefs.getString("showEmojiReactions", ShowEmojiReactions.HIDE_EMPTY.name())); showEmojiReactionsInLists=prefs.getBoolean("showEmojiReactionsInLists", false);
color=prefs.contains("color") ? ColorPreference.valueOf(prefs.getString("color", null)) : null;
recentCustomEmoji=fromJson(prefs.getString("recentCustomEmoji", null), recentCustomEmojiType, new ArrayList<>());
// MOSHIDON // MOSHIDON
// recentEmojis=fromJson(prefs.getString("recentEmojis", "{}"), recentEmojisType, new HashMap<>()); recentEmojis=fromJson(prefs.getString("recentEmojis", "{}"), recentEmojisType, new HashMap<>());
notificationFilters=fromJson(prefs.getString("notificationFilters", gson.toJson(PushSubscription.Alerts.ofAll())), notificationFiltersType, PushSubscription.Alerts.ofAll());
} }
public long getNotificationsPauseEndTime(){ public long getNotificationsPauseEndTime(){
@ -105,10 +87,6 @@ public class AccountLocalPreferences{
prefs.edit().putLong("notificationsPauseTime", time).apply(); prefs.edit().putLong("notificationsPauseTime", time).apply();
} }
public ColorPreference getCurrentColor(){
return color!=null ? color : GlobalUserPreferences.color!=null ? GlobalUserPreferences.color : ColorPreference.MATERIAL3;
}
public void save(){ public void save(){
prefs.edit() prefs.edit()
.putBoolean("interactionCounts", showInteractionCounts) .putBoolean("interactionCounts", showInteractionCounts)
@ -117,9 +95,6 @@ public class AccountLocalPreferences{
.putBoolean("hideSensitive", hideSensitiveMedia) .putBoolean("hideSensitive", hideSensitiveMedia)
.putBoolean("serverSideFilters", serverSideFiltersSupported) .putBoolean("serverSideFilters", serverSideFiltersSupported)
//TODO figure this stuff out
// .putBoolean("preReplySheet", preReplySheet)
// MEGALODON // MEGALODON
.putBoolean("showReplies", showReplies) .putBoolean("showReplies", showReplies)
.putBoolean("showBoosts", showBoosts) .putBoolean("showBoosts", showBoosts)
@ -134,47 +109,10 @@ public class AccountLocalPreferences{
.putString("timelineReplyVisibility", timelineReplyVisibility) .putString("timelineReplyVisibility", timelineReplyVisibility)
.putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification) .putBoolean("keepOnlyLatestNotification", keepOnlyLatestNotification)
.putBoolean("emojiReactionsEnabled", emojiReactionsEnabled) .putBoolean("emojiReactionsEnabled", emojiReactionsEnabled)
.putString("showEmojiReactions", showEmojiReactions.name()) .putBoolean("showEmojiReactionsInLists", showEmojiReactionsInLists)
.putString("color", color!=null ? color.name() : null)
.putString("recentCustomEmoji", gson.toJson(recentCustomEmoji))
// MOSHIDON // MOSHIDON
// .putString("recentEmojis", gson.toJson(recentEmojis)) .putString("recentEmojis", gson.toJson(recentEmojis))
.putString("notificationFilters", gson.toJson(notificationFilters))
.apply(); .apply();
} }
public enum ColorPreference{
MATERIAL3,
PURPLE,
PINK,
GREEN,
BLUE,
BROWN,
RED,
YELLOW,
NORD,
WHITE;
public @StringRes int getName() {
return switch(this){
case MATERIAL3 -> R.string.sk_color_palette_material3;
case PINK -> R.string.sk_color_palette_pink;
case PURPLE -> R.string.sk_color_palette_purple;
case GREEN -> R.string.sk_color_palette_green;
case BLUE -> R.string.sk_color_palette_blue;
case BROWN -> R.string.sk_color_palette_brown;
case RED -> R.string.sk_color_palette_red;
case YELLOW -> R.string.sk_color_palette_yellow;
case NORD -> R.string.mo_color_palette_nord;
case WHITE -> R.string.mo_color_palette_black_and_white;
};
}
}
public enum ShowEmojiReactions{
HIDE_EMPTY,
ONLY_OPENED,
ALWAYS
}
} }

View file

@ -21,13 +21,11 @@ import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AltTextFilter;
import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.PushSubscription;
@ -36,14 +34,13 @@ import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token; import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ObjectIdComparator;
import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Predicate;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
@ -74,7 +71,6 @@ public class AccountSession{
private transient SharedPreferences prefs; private transient SharedPreferences prefs;
private transient boolean preferencesNeedSaving; private transient boolean preferencesNeedSaving;
private transient AccountLocalPreferences localPreferences; private transient AccountLocalPreferences localPreferences;
private transient List<FollowList> lists;
AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){
this.token=token; this.token=token;
@ -150,9 +146,6 @@ public class AccountSession{
@Override @Override
public void onError(ErrorResponse error){ public void onError(ErrorResponse error){
Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error); Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error);
if (preferences==null)
preferences=new Preferences();
preferencesFromAccountSource(self);
} }
}) })
.exec(getID()); .exec(getID());
@ -223,7 +216,7 @@ public class AccountSession{
public void savePreferencesIfPending(){ public void savePreferencesIfPending(){
if(preferencesNeedSaving){ if(preferencesNeedSaving){
new UpdateAccountCredentialsPreferences(preferences, self.locked, self.discoverable, self.source.indexable) new UpdateAccountCredentialsPreferences(preferences, null, null)
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(Account result){ public void onSuccess(Account result){
@ -259,95 +252,52 @@ public class AccountSession{
filterStatusContainingObjects(objects, extractor, context, null); filterStatusContainingObjects(objects, extractor, context, null);
} }
private boolean statusIsOnOwnProfile(Status s, Account profile){
return self != null && profile != null && s.account != null
&& Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id);
}
private boolean isFilteredType(Status s){
AccountLocalPreferences localPreferences = getLocalPreferences();
return (!localPreferences.showReplies && s.inReplyToId != null)
|| (!localPreferences.showBoosts && s.reblog != null);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context, Account profile){ public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context, Account profile){
AccountLocalPreferences localPreferences = getLocalPreferences(); Predicate<Status> statusIsOnOwnProfile = (s) -> self != null && profile != null && s.account != null
if(!localPreferences.serverSideFiltersSupported) for(T obj:objects){ && Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id);
if(getLocalPreferences().serverSideFiltersSupported){
// Even with server-side filters, clients are expected to remove statuses that match a filter that hides them
objects.removeIf(o->{
Status s=extractor.apply(o);
if(s==null)
return false;
if(s.filtered==null)
return false;
// don't hide own posts in own profile
if (statusIsOnOwnProfile.test(s))
return false;
for(FilterResult filter:s.filtered){
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE)
return true;
}
return false;
});
return;
}
if(wordFilters==null)
return;
for(T obj:objects){
Status s=extractor.apply(obj); Status s=extractor.apply(obj);
if(s!=null && s.filtered!=null){ if(s!=null && s.filtered!=null){
localPreferences.serverSideFiltersSupported=true; getLocalPreferences().serverSideFiltersSupported=true;
localPreferences.save(); getLocalPreferences().save();
break; return;
} }
} }
objects.removeIf(o->{
List<T> removeUs=new ArrayList<>(); Status s=extractor.apply(o);
for(int i=0; i<objects.size(); i++){ if(s==null)
T o=objects.get(i);
if(filterStatusContainingObject(o, extractor, context, profile)){
Status s=extractor.apply(o);
removeUs.add(o);
if(s!=null && s.hasGapAfter!=null && i>0){
// oops, we're about to remove an item that has a gap after...
// gotta find the previous status that's not also about to be removed
for(int j=i-1; j>=0; j--){
T p=objects.get(j);
Status prev=extractor.apply(objects.get(j));
if(prev!=null && !removeUs.contains(p)){
prev.hasGapAfter=s.hasGapAfter;
break;
}
}
}
}
}
objects.removeAll(removeUs);
}
public <T> boolean filterStatusContainingObject(T object, Function<T, Status> extractor, FilterContext context, Account profile){
Status s=extractor.apply(object);
if(s==null)
return false;
// don't hide own posts in own profile
if(statusIsOnOwnProfile(s, profile))
return false;
if(isFilteredType(s) && (context == FilterContext.HOME || context == FilterContext.PUBLIC))
return true;
// Even with server-side filters, clients are expected to remove statuses that match a filter that hides them
if(getLocalPreferences().serverSideFiltersSupported){
// Moshidon: this code path in CustomLocalTimelines makes the app crash, so this check is here
if (s.filtered == null)
return false; return false;
for(FilterResult filter : s.filtered){ // don't hide own posts in own profile
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE && filter.filter.context.contains(context)) if (statusIsOnOwnProfile.test(s))
return true; return false;
} for(LegacyFilter filter:wordFilters){
}else if(wordFilters!=null){
for(LegacyFilter filter : wordFilters){
if(filter.context.contains(context) && filter.matches(s) && filter.isActive()) if(filter.context.contains(context) && filter.matches(s) && filter.isActive())
return true; return true;
} }
} return false;
return false; });
}
public List<FilterResult> getClientSideFilters(Status status) {
List<FilterResult> filters = new ArrayList<>();
// filter post that have no alt text
// it only applies when activated in the settings
AltTextFilter altTextFilter=new AltTextFilter(FilterAction.WARN, EnumSet.allOf(FilterContext.class));
if(altTextFilter.matches(status)){
FilterResult filterResult=new FilterResult();
filterResult.filter=altTextFilter;
filterResult.keywordMatches=List.of();
filters.add(filterResult);
}
return filters;
}
public void updateAccountInfo(){
AccountSessionManager.getInstance().updateSessionLocalInfo(this);
} }
public Optional<Instance> getInstance() { public Optional<Instance> getInstance() {
@ -360,18 +310,4 @@ public class AccountSession{
.authority(getInstance().map(i -> i.normalizedUri).orElse(domain)) .authority(getInstance().map(i -> i.normalizedUri).orElse(domain))
.build(); .build();
} }
public String getDefaultAvatarUrl() {
return getInstance()
.map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png"))
.orElse("");
}
public boolean isNotificationsMentionsOnly(){
return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false);
}
public void setNotificationsMentionsOnly(boolean mentionsOnly){
getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply();
}
} }

View file

@ -12,11 +12,10 @@ import android.graphics.drawable.Icon;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.util.Log; import android.util.Log;
import android.widget.Toast;
import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E; import org.joinmastodon.android.E;
import org.joinmastodon.android.ChooseAccountForComposeActivity; import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
@ -35,8 +34,6 @@ import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Token; import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.UnifiedPushHelper;
import org.unifiedpush.android.connector.UnifiedPush;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@ -72,6 +69,7 @@ public class AccountSessionManager{
private HashMap<String, List<EmojiCategory>> customEmojis=new HashMap<>(); private HashMap<String, List<EmojiCategory>> customEmojis=new HashMap<>();
private HashMap<String, Long> instancesLastUpdated=new HashMap<>(); private HashMap<String, Long> instancesLastUpdated=new HashMap<>();
private HashMap<String, Instance> instances=new HashMap<>(); private HashMap<String, Instance> instances=new HashMap<>();
private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null);
private Instance authenticatingInstance; private Instance authenticatingInstance;
private Application authenticatingApp; private Application authenticatingApp;
private String lastActiveAccountID; private String lastActiveAccountID;
@ -95,7 +93,6 @@ public class AccountSessionManager{
private AccountSessionManager(){ private AccountSessionManager(){
prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE); prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE);
// This file should not be backed up, otherwise the app may start with accounts already logged in. See res/xml/backup_rules.xml
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
if(!file.exists()) if(!file.exists())
return; return;
@ -110,12 +107,11 @@ public class AccountSessionManager{
Log.e(TAG, "Error loading accounts", x); Log.e(TAG, "Error loading accounts", x);
} }
lastActiveAccountID=prefs.getString("lastActiveAccount", null); lastActiveAccountID=prefs.getString("lastActiveAccount", null);
readInstanceInfo(domains); MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
maybeUpdateShortcuts(); maybeUpdateShortcuts();
} }
public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){ public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){
Context context = MastodonApp.context;
instances.put(instance.uri, instance); instances.put(instance.uri, instance);
AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo); AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo);
sessions.put(session.getID(), session); sessions.put(session.getID(), session);
@ -127,15 +123,8 @@ public class AccountSessionManager{
wrapper.instance = instance; wrapper.instance = instance;
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri)); MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri));
updateMoreInstanceInfo(instance, AccountSessionManager.get(session.getID()).domain); updateMoreInstanceInfo(instance, instance.uri);
if (UnifiedPushHelper.isUnifiedPushEnabled(context)) { if(PushSubscriptionManager.arePushNotificationsAvailable()){
UnifiedPush.register(
context,
session.getID(),
null,
session.app.vapidKey.replaceAll("=","")
);
} else if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush(null); session.getPushSubscriptionManager().registerAccountForPush(null);
} }
maybeUpdateShortcuts(); maybeUpdateShortcuts();
@ -216,17 +205,12 @@ public class AccountSessionManager{
public void removeAccount(String id){ public void removeAccount(String id){
AccountSession session=getAccount(id); AccountSession session=getAccount(id);
session.getCacheController().closeDatabase(); session.getCacheController().closeDatabase();
session.getCacheController().getListsFile().delete();
MastodonApp.context.deleteDatabase(id+".db"); MastodonApp.context.deleteDatabase(id+".db");
MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit(); MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
MastodonApp.context.deleteSharedPreferences(id); MastodonApp.context.deleteSharedPreferences(id);
}else{ }else{
String dataDir=MastodonApp.context.getApplicationInfo().dataDir; new File(MastodonApp.context.getDir("shared_prefs", Context.MODE_PRIVATE), id+".xml").delete();
if(dataDir!=null){
File prefsDir=new File(dataDir, "shared_prefs");
new File(prefsDir, id+".xml").delete();
}
} }
sessions.remove(id); sessions.remove(id);
if(lastActiveAccountID.equals(id)){ if(lastActiveAccountID.equals(id)){
@ -248,6 +232,11 @@ public class AccountSessionManager{
maybeUpdateShortcuts(); maybeUpdateShortcuts();
} }
@NonNull
public MastodonAPIController getUnauthenticatedApiController(){
return unauthenticatedApiController;
}
public void authenticate(Activity activity, Instance instance){ public void authenticate(Activity activity, Instance instance){
authenticatingInstance=instance; authenticatingInstance=instance;
new CreateOAuthApp() new CreateOAuthApp()
@ -325,7 +314,8 @@ public class AccountSessionManager{
} }
} }
/*package*/ void updateSessionLocalInfo(AccountSession session){
private void updateSessionLocalInfo(AccountSession session){
new GetOwnAccount() new GetOwnAccount()
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
@ -391,7 +381,7 @@ public class AccountSessionManager{
public void onError(ErrorResponse errorResponse) { public void onError(ErrorResponse errorResponse) {
updateInstanceEmojis(instance, domain); updateInstanceEmojis(instance, domain);
} }
}).execNoAuth(domain); }).execNoAuth(instance.uri);
} }
private void updateInstanceEmojis(Instance instance, String domain){ private void updateInstanceEmojis(Instance instance, String domain){
@ -485,19 +475,15 @@ public class AccountSessionManager{
if(Build.VERSION.SDK_INT<26) if(Build.VERSION.SDK_INT<26)
return; return;
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class); ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
Intent intent = new Intent(MastodonApp.context, ChooseAccountForComposeActivity.class)
.setAction(Intent.ACTION_CHOOSER)
.putExtra("compose", true);
// This was done so that the old shortcuts get updated to the new implementation.
if((sm.getDynamicShortcuts().isEmpty() || sm.getDynamicShortcuts().get(0).getIntent() != intent || BuildConfig.DEBUG ) && !sessions.isEmpty()){
// There are no shortcuts, but there are accounts. Add a compose shortcut. // There are no shortcuts, but there are accounts. Add a compose shortcut.
ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose") ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose")
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName())) .setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
.setShortLabel(MastodonApp.context.getString(R.string.new_post)) .setShortLabel(MastodonApp.context.getString(R.string.new_post))
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose)) .setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose))
.setIntent(intent) .setIntent(new Intent(MastodonApp.context, MainActivity.class)
.setAction(Intent.ACTION_MAIN)
.putExtra("compose", true))
.build(); .build();
sm.setDynamicShortcuts(Collections.singletonList(info)); sm.setDynamicShortcuts(Collections.singletonList(info));
}else if(sessions.isEmpty()){ }else if(sessions.isEmpty()){

View file

@ -1,15 +0,0 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Account;
public class AccountAddedToListEvent{
public final String accountID;
public final String listID;
public final Account account;
public AccountAddedToListEvent(String accountID, String listID, Account account){
this.accountID=accountID;
this.listID=listID;
this.account=account;
}
}

View file

@ -1,13 +0,0 @@
package org.joinmastodon.android.events;
public class AccountRemovedFromListEvent{
public final String accountID;
public final String listID;
public final String targetAccountID;
public AccountRemovedFromListEvent(String accountID, String listID, String targetAccountID){
this.accountID=accountID;
this.listID=listID;
this.targetAccountID=targetAccountID;
}
}

View file

@ -1,19 +0,0 @@
package org.joinmastodon.android.events;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.model.EmojiReaction;
import java.util.List;
public class EmojiReactionsUpdatedEvent{
public final String id;
public final List<EmojiReaction> reactions;
public final boolean updateTextPadding;
public RecyclerView.ViewHolder viewHolder;
public EmojiReactionsUpdatedEvent(String id, List<EmojiReaction> reactions, boolean updateTextPadding, RecyclerView.ViewHolder viewHolder){
this.id=id;
this.reactions=reactions;
this.updateTextPadding=updateTextPadding;
this.viewHolder=viewHolder;
}
}

View file

@ -1,11 +0,0 @@
package org.joinmastodon.android.events;
public class FinishListCreationFragmentEvent{
public final String accountID;
public final String listID;
public FinishListCreationFragmentEvent(String accountID, String listID){
this.accountID=accountID;
this.listID=listID;
}
}

View file

@ -1,13 +0,0 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList;
public class ListCreatedEvent{
public final String accountID;
public final FollowList list;
public ListCreatedEvent(String accountID, FollowList list){
this.accountID=accountID;
this.list=list;
}
}

View file

@ -1,11 +1,9 @@
package org.joinmastodon.android.events; package org.joinmastodon.android.events;
public class ListDeletedEvent{ public class ListDeletedEvent {
public final String accountID; public final String id;
public final String listID;
public ListDeletedEvent(String accountID, String listID){ public ListDeletedEvent(String id) {
this.accountID=accountID; this.id = id;
this.listID=listID;
} }
} }

View file

@ -1,14 +1,14 @@
package org.joinmastodon.android.events; package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.ListTimeline;
public class ListUpdatedCreatedEvent { public class ListUpdatedCreatedEvent {
public final String id; public final String id;
public final String title; public final String title;
public final FollowList.RepliesPolicy repliesPolicy; public final ListTimeline.RepliesPolicy repliesPolicy;
public final boolean exclusive; public final boolean exclusive;
public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, FollowList.RepliesPolicy repliesPolicy) { public ListUpdatedCreatedEvent(String id, String title, boolean exclusive, ListTimeline.RepliesPolicy repliesPolicy) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.exclusive = exclusive; this.exclusive = exclusive;

View file

@ -1,13 +0,0 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList;
public class ListUpdatedEvent{
public final String accountID;
public final FollowList list;
public ListUpdatedEvent(String accountID, FollowList list){
this.accountID=accountID;
this.list=list;
}
}

View file

@ -1,11 +0,0 @@
package org.joinmastodon.android.events;
public class ReblogDeletedEvent{
public final String statusID;
public final String accountID;
public ReblogDeletedEvent(String statusID, String accountID){
this.statusID=statusID;
this.accountID=accountID;
}
}

View file

@ -1,5 +1,7 @@
package org.joinmastodon.android.events; package org.joinmastodon.android.events;
import org.joinmastodon.android.model.ScheduledStatus;
public class ScheduledStatusDeletedEvent{ public class ScheduledStatusDeletedEvent{
public final String id; public final String id;
public final String accountID; public final String accountID;

View file

@ -1,14 +1,27 @@
package org.joinmastodon.android.events; package org.joinmastodon.android.events;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.api.CacheController;
import org.joinmastodon.android.model.EmojiReaction;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import java.util.ArrayList;
import java.util.List;
public class StatusCountersUpdatedEvent{ public class StatusCountersUpdatedEvent{
public String id; public String id;
public long favorites, reblogs, replies; public long favorites, reblogs, replies;
public boolean favorited, reblogged, bookmarked, pinned; public boolean favorited, reblogged, bookmarked, pinned;
public List<EmojiReaction> reactions;
public Status status; public Status status;
public RecyclerView.ViewHolder viewHolder;
public StatusCountersUpdatedEvent(Status s){ public StatusCountersUpdatedEvent(Status s){
this(s, null);
}
public StatusCountersUpdatedEvent(Status s, RecyclerView.ViewHolder vh){
id=s.id; id=s.id;
status=s; status=s;
favorites=s.favouritesCount; favorites=s.favouritesCount;
@ -18,5 +31,7 @@ public class StatusCountersUpdatedEvent{
reblogged=s.reblogged; reblogged=s.reblogged;
bookmarked=s.bookmarked; bookmarked=s.bookmarked;
pinned=s.pinned; pinned=s.pinned;
reactions=new ArrayList<>(s.reactions);
viewHolder=vh;
} }
} }

View file

@ -9,6 +9,5 @@ public class StatusCreatedEvent{
public StatusCreatedEvent(Status status, String accountID){ public StatusCreatedEvent(Status status, String accountID){
this.status=status; this.status=status;
this.accountID=accountID; this.accountID=accountID;
status.fromStatusCreated=true;
} }
} }

View file

@ -1,15 +0,0 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Status;
public class StatusMuteChangedEvent{
public String id;
public boolean muted;
public Status status;
public StatusMuteChangedEvent(Status s){
id=s.id;
muted=s.muted;
status=s;
}
}

View file

@ -9,16 +9,19 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.events.StatusUnpinnedEvent;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import org.parceler.Parcels; import org.parceler.Parcels;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.api.SimpleCallback;
@ -52,14 +55,15 @@ public class AccountTimelineFragment extends StatusListFragment{
@Override @Override
protected void doLoadData(int offset, int count){ protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(user.id, getMaxID(), null, count, filter) currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(List<Status> result){ public void onSuccess(List<Status> result){
if(getActivity()==null) return; if(getActivity()==null) return;
boolean more=applyMaxID(result); AccountSessionManager asm = AccountSessionManager.getInstance();
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user); AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user);
onDataLoaded(result, more); onDataLoaded(result, !empty);
} }
}) })
.exec(accountID); .exec(accountID);

View file

@ -1,114 +0,0 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountLists;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.AccountAddedToListEvent;
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class AddAccountToListsFragment extends BaseSettingsFragment<FollowList>{
private Account account;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.add_user_to_list_title);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
loadData();
}
@Override
protected void doLoadData(int offset, int count){
AccountSessionManager.get(accountID).getCacheController().getLists(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> allLists){
if(getActivity()==null)
return;
loadAccountLists(allLists);
}
});
}
private void loadAccountLists(final List<FollowList> allLists){
currentRequest=new GetAccountLists(account.id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> result){
Set<String> lists=result.stream().map(l->l.id).collect(Collectors.toSet());
onDataLoaded(allLists.stream()
.map(l->new CheckableListItem<>(l.title, null, CheckableListItem.Style.CHECKBOX, lists.contains(l.id),
R.drawable.ic_list_alt_24px, AddAccountToListsFragment.this::onItemClick, l))
.collect(Collectors.toList()), false);
}
})
.exec(accountID);
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
TextView topText=new TextView(getActivity());
topText.setTextAppearance(R.style.m3_body_medium);
topText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface));
topText.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8));
topText.setText(getString(R.string.manage_user_lists, account.getDisplayUsername()));
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(topText));
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
private void onItemClick(CheckableListItem<FollowList> item){
boolean add=!item.checked;
ResultlessMastodonAPIRequest req=add ? new AddAccountsToList(item.parentObject.id, Set.of(account.id)) : new RemoveAccountsFromList(item.parentObject.id, Set.of(account.id));
req.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
item.checked=add;
rebindItem(item);
if(add){
E.post(new AccountAddedToListEvent(accountID, item.parentObject.id, account));
}else{
E.post(new AccountRemovedFromListEvent(accountID, item.parentObject.id, account.id));
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
}

View file

@ -68,14 +68,14 @@ public class AnnouncementsFragment extends BaseStatusListFragment<Announcement>
instanceUser.url = "https://"+session.domain+"/about"; instanceUser.url = "https://"+session.domain+"/about";
instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail; instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail;
instanceUser.emojis = List.of(); instanceUser.emojis = List.of();
Status fakeStatus = a.toStatus(isInstanceIceshrimp()); Status fakeStatus = a.toStatus();
TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true); TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true);
textItem.textSelectable = true; textItem.textSelectable = true;
List<StatusDisplayItem> items=new ArrayList<>(); List<StatusDisplayItem> items=new ArrayList<>();
items.add(HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead)); items.add(HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead));
items.add(textItem); items.add(textItem);
if(!isInstanceAkkoma() && !isInstanceIceshrimp()) items.add(new EmojiReactionsStatusDisplayItem(a.id, this, fakeStatus, accountID, false, true)); if(!isInstanceAkkoma()) items.add(new EmojiReactionsStatusDisplayItem(a.id, this, fakeStatus, accountID, false, true));
return items; return items;
} }
@ -97,7 +97,7 @@ public class AnnouncementsFragment extends BaseStatusListFragment<Announcement>
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(List<Announcement> result){ public void onSuccess(List<Announcement> result){
if(getActivity()==null) return; if (getActivity() == null) return;
// get unread items first // get unread items first
List<Announcement> data = result.stream().filter(a -> !a.read).collect(toList()); List<Announcement> data = result.stream().filter(a -> !a.read).collect(toList());

View file

@ -1,176 +0,0 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.DeleteList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public abstract class BaseEditListFragment extends BaseSettingsFragment<Void>{
protected FollowList followList;
protected AvatarPileListItem<Void> membersItem;
protected CheckableListItem<Void> exclusiveItem;
protected FloatingHintEditTextLayout titleEditLayout;
protected EditText titleEdit;
protected Spinner showRepliesSpinner;
private APIRequest<?> getMembersRequest;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
membersItem=new AvatarPileListItem<>(getString(R.string.list_members), null, List.of(), 0, i->onMembersClick(), null, false);
List<ListItem<Void>> items=new ArrayList<>();
if(followList!=null){
items.add(membersItem);
}
exclusiveItem=new CheckableListItem<>(R.string.list_exclusive, R.string.list_exclusive_subtitle, CheckableListItem.Style.SWITCH, followList!=null && followList.exclusive, this::toggleCheckableItem);
items.add(exclusiveItem);
onDataLoaded(items);
}
@Override
public void onDestroy(){
super.onDestroy();
if(getMembersRequest!=null)
getMembersRequest.cancel();
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
LinearLayout topView=new LinearLayout(getActivity());
topView.setOrientation(LinearLayout.VERTICAL);
titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, topView, false);
titleEdit=titleEditLayout.findViewById(R.id.edit);
titleEdit.setHint(R.string.list_name);
titleEditLayout.updateHint();
if(followList!=null)
titleEdit.setText(followList.title);
topView.addView(titleEditLayout);
FloatingHintEditTextLayout showRepliesLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_spinner, topView, false);
showRepliesSpinner=showRepliesLayout.findViewById(R.id.spinner);
showRepliesLayout.setHint(R.string.list_show_replies_to);
topView.addView(showRepliesLayout);
ArrayAdapter<String> spinnerAdapter=new ArrayAdapter<>(getActivity(), R.layout.item_spinner, List.of(
getString(R.string.list_replies_no_one),
getString(R.string.list_replies_members),
getString(R.string.list_replies_anyone)
));
showRepliesSpinner.setAdapter(spinnerAdapter);
showRepliesSpinner.setSelection(switch(followList!=null ? followList.repliesPolicy : FollowList.RepliesPolicy.LIST){
case FOLLOWED -> 2;
case LIST -> 1;
case NONE -> 0;
});
ViewGroup.MarginLayoutParams llp=(ViewGroup.MarginLayoutParams)showRepliesLayout.getLabel().getLayoutParams();
llp.setMarginStart(llp.getMarginStart()+V.dp(16));
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(topView));
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
protected void doDeleteList(){
new DeleteList(followList.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
AccountSessionManager.get(accountID).getCacheController().deleteList(followList.id);
E.post(new ListDeletedEvent(accountID, followList.id));
Nav.finish(BaseEditListFragment.this);
}
@Override
public void onError(ErrorResponse error){
Activity activity=getActivity();
if(activity==null)
return;
error.showToast(activity);
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void onMembersClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
Nav.go(getActivity(), ListMembersFragment.class, args);
}
protected void loadMembers(){
getMembersRequest=new GetListAccounts(followList.id, null, 3)
.setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
getMembersRequest=null;
membersItem.avatars=new ArrayList<>();
for(int i=0;i<Math.min(3, result.size());i++){
Account acc=result.get(i);
membersItem.avatars.add(new UrlImageLoaderRequest(acc.avatarStatic, V.dp(32), V.dp(32)));
}
rebindItem(membersItem);
imgLoader.updateImages();
}
@Override
public void onError(ErrorResponse error){
getMembersRequest=null;
}
})
.exec(accountID);
}
protected FollowList.RepliesPolicy getSelectedRepliesPolicy(){
return switch(showRepliesSpinner.getSelectedItemPosition()){
case 0 -> FollowList.RepliesPolicy.NONE;
case 1 -> FollowList.RepliesPolicy.LIST;
case 2 -> FollowList.RepliesPolicy.FOLLOWED;
default -> throw new IllegalStateException("Unexpected value: "+showRepliesSpinner.getSelectedItemPosition());
};
}
}

View file

@ -9,7 +9,7 @@ import android.graphics.Rect;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Pair; import android.util.Log;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; import android.view.WindowInsets;
@ -24,27 +24,18 @@ import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E; import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.CacheController;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote; import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus;
import org.joinmastodon.android.api.requests.statuses.TranslateStatus;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AkkomaTranslation;
import org.joinmastodon.android.model.DisplayItemsParent; import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.OldPostPreReplySheet;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
@ -53,34 +44,33 @@ import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PreviewlessMediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.MediaAttachmentViewController; import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.TypedObjectPool; import org.joinmastodon.android.utils.TypedObjectPool;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav; import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
@ -100,10 +90,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected HashMap<String, Relationship> relationships=new HashMap<>(); protected HashMap<String, Relationship> relationships=new HashMap<>();
protected Rect tmpRect=new Rect(); protected Rect tmpRect=new Rect();
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView); protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView);
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> previewlessAttachmentViewsPool=new TypedObjectPool<>(this::makeNewPreviewlessMediaAttachmentView);
protected boolean currentlyScrolling; protected boolean currentlyScrolling;
protected String maxID;
public BaseStatusListFragment(){ public BaseStatusListFragment(){
super(20); super(20);
@ -145,7 +132,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
for(T s:items){ for(T s:items){
displayItems.addAll(buildDisplayItems(s)); displayItems.addAll(buildDisplayItems(s));
} }
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
} }
@Override @Override
@ -167,13 +153,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
} }
if(notify) if(notify)
adapter.notifyItemRangeInserted(0, offset); adapter.notifyItemRangeInserted(0, offset);
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
return offset; return offset;
} }
protected String getMaxID(){ protected String getMaxID(){
if(refreshing) return null;
if(maxID!=null) return maxID;
if(!preloadedData.isEmpty()) if(!preloadedData.isEmpty())
return preloadedData.get(preloadedData.size()-1).getID(); return preloadedData.get(preloadedData.size()-1).getID();
else if(!data.isEmpty()) else if(!data.isEmpty())
@ -182,12 +165,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null; return null;
} }
protected boolean applyMaxID(List<Status> result){
boolean empty=result.isEmpty();
if(!empty) maxID=result.get(result.size()-1).id;
return !empty;
}
protected abstract List<StatusDisplayItem> buildDisplayItems(T s); protected abstract List<StatusDisplayItem> buildDisplayItems(T s);
protected abstract void addAccountToKnown(T s); protected abstract void addAccountToKnown(T s);
@ -224,7 +201,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override @Override
public void openPhotoViewer(String parentID, Status _status, int attachmentIndex, MediaGridStatusDisplayItem.Holder gridHolder){ public void openPhotoViewer(String parentID, Status _status, int attachmentIndex, MediaGridStatusDisplayItem.Holder gridHolder){
final Status status=_status.getContentStatus(); final Status status=_status.getContentStatus();
currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){ currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){
private MediaAttachmentViewController transitioningHolder; private MediaAttachmentViewController transitioningHolder;
@Override @Override
@ -290,7 +267,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override @Override
public void photoViewerDismissed(){ public void photoViewerDismissed(){
currentPhotoViewer=null; currentPhotoViewer=null;
gridHolder.itemView.setHasTransientState(false);
} }
@Override @Override
@ -302,80 +278,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return gridHolder.getViewController(index); return gridHolder.getViewController(index);
} }
}); });
gridHolder.itemView.setHasTransientState(true);
}
public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){
final Status status=_status.getContentStatus();
currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){
private PreviewlessMediaAttachmentViewController transitioningHolder;
@Override
public void setPhotoViewVisibility(int index, boolean visible){
}
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
PreviewlessMediaAttachmentViewController holder=findPhotoViewHolder(index);
if(holder!=null && list!=null){
transitioningHolder=holder;
View view=transitioningHolder.inner;
int[] pos={0, 0};
view.getLocationOnScreen(pos);
outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight());
list.setClipChildren(false);
gridHolder.setClipChildren(false);
transitioningHolder.view.setElevation(1f);
return true;
}
return false;
}
@Override
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
View view=transitioningHolder.inner;
view.setTranslationX(translateX);
view.setTranslationY(translateY);
view.setScaleX(scale);
view.setScaleY(scale);
}
@Override
public void endPhotoViewTransition(){
View view=transitioningHolder.inner;
view.setTranslationX(0f);
view.setTranslationY(0f);
view.setScaleX(1f);
view.setScaleY(1f);
transitioningHolder.view.setElevation(0f);
if(list!=null)
list.setClipChildren(true);
gridHolder.setClipChildren(true);
transitioningHolder=null;
}
@Nullable
@Override
public Drawable getPhotoViewCurrentDrawable(int index){
return null;
}
@Override
public void photoViewerDismissed(){
currentPhotoViewer=null;
}
@Override
public void onRequestPermissions(String[] permissions){
requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
}
private PreviewlessMediaAttachmentViewController findPhotoViewHolder(int index){
return gridHolder.getViewController(index);
}
});
} }
@Override @Override
@ -450,14 +352,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
} }
}); });
list.addItemDecoration(new StatusListItemDecoration()); list.addItemDecoration(new StatusListItemDecoration());
list.addItemDecoration(new InsetStatusItemDecoration(this));
((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){ ((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){
private Rect tmpRect=new Rect(); private Rect tmpRect=new Rect();
@Override @Override
public void getSelectorBounds(View view, Rect outRect){ public void getSelectorBounds(View view, Rect outRect){
if(list!=view.getParent()) return; boolean hasDescendant = false, hasAncestor = false, isWarning = false;
boolean hasDescendant=false, hasAncestor=false, isWarning=false; int lastIndex = -1, firstIndex = -1;
int lastIndex=-1, firstIndex=-1;
if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){ if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){
list.getDecoratedBoundsWithMargins(view, outRect); list.getDecoratedBoundsWithMargins(view, outRect);
}else{ }else{
@ -562,14 +462,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected void updatePoll(String itemID, Status status, Poll poll){ protected void updatePoll(String itemID, Status status, Poll poll){
status.poll=poll; status.poll=poll;
int firstOptionIndex=-1, footerIndex=-1; int firstOptionIndex=-1, footerIndex=-1;
int spoilerFirstOptionIndex=-1, spoilerFooterIndex=-1;
SpoilerStatusDisplayItem spoilerItem=null;
int i=0; int i=0;
for(StatusDisplayItem item:displayItems){ for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(itemID)){ if(item.parentID.equals(itemID)){
if(item instanceof SpoilerStatusDisplayItem){ if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i; firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){ }else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i; footerIndex=i;
@ -578,39 +474,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
} }
i++; i++;
} }
// This is a temporary measure to deal with the app crashing when the poll isn't updated.
// This is needed because of a possible id mismatch that screws with things
if(firstOptionIndex==-1 || footerIndex==-1){
for(StatusDisplayItem item:displayItems){
if(status.id.equals(itemID)){
if(item instanceof SpoilerStatusDisplayItem){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
break;
}
}
i++;
}
}
if(firstOptionIndex==-1 || footerIndex==-1) if(firstOptionIndex==-1 || footerIndex==-1)
throw new IllegalStateException("Can't find all poll items in displayItems"); throw new IllegalStateException("Can't find all poll items in displayItems");
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1); List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
int prevSize=pollItems.size(); int prevSize=pollItems.size();
if(spoilerItem!=null){
spoilerFirstOptionIndex=spoilerItem.contentItems.indexOf(pollItems.get(0));
spoilerFooterIndex=spoilerItem.contentItems.indexOf(pollItems.get(pollItems.size()-1));
}
pollItems.clear(); pollItems.clear();
StatusDisplayItem.buildPollItems(itemID, this, poll, status, pollItems); StatusDisplayItem.buildPollItems(itemID, this, poll, pollItems, status);
if(spoilerItem!=null){
spoilerItem.contentItems.subList(spoilerFirstOptionIndex, spoilerFooterIndex+1).clear();
spoilerItem.contentItems.addAll(spoilerFirstOptionIndex, pollItems);
}
if(prevSize!=pollItems.size()){ if(prevSize!=pollItems.size()){
adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize); adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize);
adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size()); adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size());
@ -622,8 +491,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){ public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){
Poll poll=holder.getItem().poll; Poll poll=holder.getItem().poll;
Poll.Option option=holder.getItem().option; Poll.Option option=holder.getItem().option;
// MEGALODON: always show vote button if(poll.multiple || GlobalUserPreferences.voteButtonForSingleChoice){
// if(poll.multiple){
if(poll.selectedOptions==null) if(poll.selectedOptions==null)
poll.selectedOptions=new ArrayList<>(); poll.selectedOptions=new ArrayList<>();
boolean optionContained=poll.selectedOptions.contains(option); boolean optionContained=poll.selectedOptions.contains(option);
@ -638,7 +506,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
for(int i=0;i<list.getChildCount();i++){ for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder vh=list.getChildViewHolder(list.getChildAt(i)); RecyclerView.ViewHolder vh=list.getChildViewHolder(list.getChildAt(i));
if(!poll.multiple && vh instanceof PollOptionStatusDisplayItem.Holder item){ if(!poll.multiple && vh instanceof PollOptionStatusDisplayItem.Holder item){
if(item!=holder) item.itemView.setSelected(false); if (item != holder) item.itemView.setSelected(false);
} }
if(vh instanceof PollFooterStatusDisplayItem.Holder footer){ if(vh instanceof PollFooterStatusDisplayItem.Holder footer){
if(footer.getItemID().equals(holder.getItemID())){ if(footer.getItemID().equals(holder.getItemID())){
@ -647,9 +515,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
} }
} }
} }
// }else{ }else{
// submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option))); submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
// } }
} }
public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){ public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){
@ -657,33 +525,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList())); submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList()));
} }
public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){
int firstOptionIndex=-1, footerIndex=-1;
int i=0;
for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(holder.getItemID())){
if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
break;
}
}
i++;
}
if(firstOptionIndex==-1 || footerIndex==-1)
throw new IllegalStateException("Can't find all poll items in displayItems");
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
for(StatusDisplayItem item:pollItems){
if (item instanceof PollOptionStatusDisplayItem) {
((PollOptionStatusDisplayItem) item).isAnimating=true;
((PollOptionStatusDisplayItem) item).showResults=shown;
adapter.notifyItemRangeChanged(firstOptionIndex, pollItems.size());
}
}
}
protected void submitPollVote(String parentID, String pollID, List<Integer> choices){ protected void submitPollVote(String parentID, String pollID, List<Integer> choices){
if(refreshing) if(refreshing)
return; return;
@ -705,145 +546,91 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){ public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){
Status status=holder.getItem().status; Status status=holder.getItem().status;
boolean isForQuote=holder.getItem().isForQuote; toggleSpoiler(status, holder.getItemID());
toggleSpoiler(status, isForQuote, holder.getItemID());
}
public void updateStatusWithQuote(DisplayItemsParent parent) {
Pair<Integer, Integer> items=findAllItemsOfParent(parent);
if (items==null)
return;
// Only StatusListFragments/NotificationsListFragments can display status with quotes
assert (this instanceof StatusListFragment) || (this instanceof NotificationsListFragment);
List<StatusDisplayItem> oldItems = displayItems.subList(items.first, items.second+1);
List<StatusDisplayItem> newItems=this.buildDisplayItems((T) parent);
int prevSize=oldItems.size();
oldItems.clear();
displayItems.addAll(items.first, newItems);
// Update the cache
final CacheController cache=AccountSessionManager.get(accountID).getCacheController();
if (parent instanceof Status) {
cache.updateStatus((Status) parent);
} else if (parent instanceof Notification) {
cache.updateNotification((Notification) parent);
}
adapter.notifyItemRangeRemoved(items.first, prevSize);
adapter.notifyItemRangeInserted(items.first, newItems.size());
}
public void removeStatus(DisplayItemsParent parent) {
Pair<Integer, Integer> items=findAllItemsOfParent(parent);
if (items==null)
return;
List<StatusDisplayItem> statusDisplayItems = displayItems.subList(items.first, items.second+1);
int prevSize=statusDisplayItems.size();
statusDisplayItems.clear();
adapter.notifyItemRangeRemoved(items.first, prevSize);
} }
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) { public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) {
Status status = holder.getItem().status; Status status = holder.getItem().status;
if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false); MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class);
MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); if (mediaGrid != null) {
if(mediaGrid!=null){ if (!status.sensitiveRevealed) mediaGrid.revealSensitive();
if(!status.sensitiveRevealed) mediaGrid.revealSensitive();
else mediaGrid.hideSensitive(); else mediaGrid.hideSensitive();
}else{ } else {
status.sensitiveRevealed=false; // media grid's methods normally change the status' state - we still want to be able
notifyItemChangedAfter(holder.getItem(), MediaGridStatusDisplayItem.class); // to do this if the media grid is not bound, tho - so, doing it ourselves here
status.sensitiveRevealed = !status.sensitiveRevealed;
} }
holder.rebind();
} }
public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) { public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) {
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
if(header!=null && header.getItem().hasVisibilityToggle) header.animateVisibilityToggle(true); if(header != null) header.rebind();
else notifyItemChangedBefore(holder.getItem(), HeaderStatusDisplayItem.class);
} }
protected void toggleSpoiler(Status status, boolean isForQuote, String itemID){ protected void toggleSpoiler(Status status, String itemID){
status.spoilerRevealed=!status.spoilerRevealed; status.spoilerRevealed=!status.spoilerRevealed;
if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs)
status.sensitiveRevealed = false; status.sensitiveRevealed = false;
List<SpoilerStatusDisplayItem.Holder> spoilers=findAllHoldersOfType(itemID, SpoilerStatusDisplayItem.Holder.class); SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
SpoilerStatusDisplayItem.Holder spoiler=spoilers.size() > 1 && isForQuote ? spoilers.get(1) : spoilers.get(0); if(spoiler!=null)
if(spoiler!=null) spoiler.rebind(); spoiler.rebind();
else notifyItemChanged(itemID, SpoilerStatusDisplayItem.class); SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class));
SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(spoiler.getItem());
int index=displayItems.indexOf(spoilerItem); int index=displayItems.indexOf(spoilerItem);
if(status.spoilerRevealed){ if(status.spoilerRevealed){
displayItems.addAll(index+1, spoilerItem.contentItems); displayItems.addAll(index+1, spoilerItem.contentItems);
adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size()); adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size());
}else{ }else{
if(spoilers.size()>1 && !isForQuote && status.quote.spoilerRevealed)
toggleSpoiler(status.quote, true, itemID);
displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear(); displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear();
adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size()); adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size());
} }
notifyItemChanged(itemID, TextStatusDisplayItem.class); TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null)
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()-getMainAdapterOffset());
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.rebind(); if(header!=null)
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class); header.rebind();
list.invalidateItemDecorations(); list.invalidateItemDecorations();
} }
public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable, boolean isForQuote) { public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) {
Status s=holder.getItem().status; if (holder.getItem().status.textExpandable != expandable && list != null) {
if(s.textExpandable!=expandable && list!=null) { holder.getItem().status.textExpandable = expandable;
s.textExpandable=expandable; HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
List<HeaderStatusDisplayItem.Holder> headers=findAllHoldersOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); if (header != null) header.rebind();
if(headers!=null && !headers.isEmpty()){
HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0);
if(header!=null) header.bindCollapseButton();
}
} }
} }
public void onToggleExpanded(Status status, boolean isForQuote, String itemID) { public void onToggleExpanded(Status status, String itemID) {
status.textExpanded = !status.textExpanded; status.textExpanded = !status.textExpanded;
// TODO: simplify this to a single case TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(!isForQuote) HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
// using the adapter directly to update the item does not work for non-quoted texts if (text != null) text.rebind();
notifyItemChanged(itemID, TextStatusDisplayItem.class); if (header != null) header.rebind();
else{
List<TextStatusDisplayItem.Holder> textItems=findAllHoldersOfType(itemID, TextStatusDisplayItem.Holder.class);
TextStatusDisplayItem.Holder text=textItems.size()>1 ? textItems.get(1) : textItems.get(0);
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition());
}
List<HeaderStatusDisplayItem.Holder> headers=findAllHoldersOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if (headers.isEmpty())
return;
HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0);
if(header!=null) header.animateExpandToggle();
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
} }
public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){} public void updateEmojiReactions(Status status, String itemID){
EmojiReactionsStatusDisplayItem.Holder reactions=findHolderOfType(itemID, EmojiReactionsStatusDisplayItem.Holder.class);
if(reactions != null){
reactions.getItem().status.reactions.clear();
reactions.getItem().status.reactions.addAll(status.reactions);
reactions.rebind();
}
}
public void onGapClick(GapStatusDisplayItem.Holder item){}
public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){
WarningFilteredStatusDisplayItem filterItem=findItemOfType(warning.getItemID(), WarningFilteredStatusDisplayItem.class); int startPos = warning.getAbsoluteAdapterPosition();
int startPos=displayItems.indexOf(filterItem);
displayItems.remove(startPos); displayItems.remove(startPos);
displayItems.addAll(startPos, warning.filteredItems); displayItems.addAll(startPos, warning.filteredItems);
adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1); adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1);
if (startPos == 0) scrollToTop(); if (startPos == 0) scrollToTop();
warning.getItem().status.filterRevealed = true; warning.getItem().status.filterRevealed = true;
list.invalidateItemDecorations();
}
public void onFavoriteChanged(Status status, String itemID) {
FooterStatusDisplayItem.Holder footer=findHolderOfType(itemID, FooterStatusDisplayItem.Holder.class);
if(footer!=null){
footer.getItem().status=status;
footer.onFavoriteClick();
}
} }
@Override @Override
@ -860,9 +647,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
} }
protected void loadRelationships(Set<String> ids){ protected void loadRelationships(Set<String> ids){
if(ids.isEmpty())
return;
ids=ids.stream().filter(id->!relationships.containsKey(id)).collect(Collectors.toSet());
if(ids.isEmpty()) if(ids.isEmpty())
return; return;
// TODO somehow manage these and cancel outstanding requests on refresh // TODO somehow manage these and cancel outstanding requests on refresh
@ -894,61 +678,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null; return null;
} }
/**
* Use this as a fallback if findHolderOfType fails to find the ViewHolder.
* It might still be bound but off-screen and therefore not a child of the RecyclerView -
* resulting in the ViewHolder displaying an outdated state once scrolled back into view.
*/
protected <I extends StatusDisplayItem> int notifyItemChanged(String id, Class<I> type){
boolean encounteredParent=false;
for(int i=0; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
boolean idEquals=id.equals(item.parentID);
if(!encounteredParent && idEquals) encounteredParent=true; // reached top of the parent
else if(encounteredParent && !idEquals) break; // passed by bottom of the parent. man muss ja wissen wann schluss is
if(idEquals && type.isInstance(item)){
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedAfter(StatusDisplayItem afterThis, Class<I> type){
int startIndex=displayItems.indexOf(afterThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedAfter didn't find the passed StatusDisplayItem");
String parentID=afterThis.parentID;
for(int i=startIndex; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedBefore(StatusDisplayItem beforeThis, Class<I> type){
int startIndex=displayItems.indexOf(beforeThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedBefore didn't find the passed StatusDisplayItem");
String parentID=beforeThis.parentID;
for(int i=startIndex; i>=0; i--){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
@Nullable @Nullable
protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> H findHolderOfType(String id, Class<H> type){ protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> H findHolderOfType(String id, Class<H> type){
for(int i=0; i<list.getChildCount(); i++){ for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i)); RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof StatusDisplayItem.Holder<?> itemHolder && itemHolder.getItemID().equals(id) && type.isInstance(holder)) if(holder instanceof StatusDisplayItem.Holder<?> itemHolder && itemHolder.getItemID().equals(id) && type.isInstance(holder))
return type.cast(holder); return type.cast(holder);
@ -956,23 +688,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null; return null;
} }
@Nullable
protected Pair<Integer, Integer> findAllItemsOfParent(DisplayItemsParent parent){
int startIndex=-1;
int endIndex=-1;
for(int i=0; i<displayItems.size(); i++){
StatusDisplayItem item = displayItems.get(i);
if(item.parentID.equals(parent.getID())) {
startIndex= startIndex==-1 ? i : startIndex;
endIndex=i;
}
}
if(startIndex==-1 || endIndex==-1)
return null;
return Pair.create(startIndex, endIndex);
}
protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> List<H> findAllHoldersOfType(String id, Class<H> type){ protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> List<H> findAllHoldersOfType(String id, Class<H> type){
ArrayList<H> holders=new ArrayList<>(); ArrayList<H> holders=new ArrayList<>();
for(int i=0;i<list.getChildCount();i++){ for(int i=0;i<list.getChildCount();i++){
@ -1053,116 +768,15 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return new MediaAttachmentViewController(getActivity(), type); return new MediaAttachmentViewController(getActivity(), type);
} }
private PreviewlessMediaAttachmentViewController makeNewPreviewlessMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){
return new PreviewlessMediaAttachmentViewController(getActivity(), type);
}
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> getAttachmentViewsPool(){ public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> getAttachmentViewsPool(){
return attachmentViewsPool; return attachmentViewsPool;
} }
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> getPreviewlessAttachmentViewsPool(){
return previewlessAttachmentViewsPool;
}
@Override @Override
public void onProvideAssistContent(AssistContent assistContent) { public void onProvideAssistContent(AssistContent assistContent) {
assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon()));
} }
public void togglePostTranslation(Status status, String itemID){
switch(status.translationState){
case LOADING -> {
return;
}
case SHOWN -> {
status.translationState=Status.TranslationState.HIDDEN;
}
case HIDDEN -> {
if(status.translation!=null){
status.translationState=Status.TranslationState.SHOWN;
}else{
status.translationState=Status.TranslationState.LOADING;
Consumer<Translation> successCallback=(result)->{
status.translation=result;
status.translationState=Status.TranslationState.SHOWN;
updateTranslation(itemID);
};
MastodonAPIRequest<?> req=isInstanceAkkoma()
? new AkkomaTranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(AkkomaTranslation result){
if(getActivity()!=null) successCallback.accept(result.toTranslation());
}
@Override
public void onError(ErrorResponse error){
if(getActivity()!=null) translationCallbackError(status, itemID);
}
})
: new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(Translation result){
if(getActivity()!=null) successCallback.accept(result);
}
@Override
public void onError(ErrorResponse error){
if(getActivity()!=null) translationCallbackError(status, itemID);
}
});
// 1 minute
req.setTimeout(60000).exec(accountID);
}
}
}
updateTranslation(itemID);
}
private void translationCallbackError(Status status, String itemID) {
status.translationState=Status.TranslationState.HIDDEN;
updateTranslation(itemID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.translation_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
private void updateTranslation(String itemID) {
TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null){
text.updateTranslation(true);
imgLoader.bindViewHolder((ImageLoaderRecyclerAdapter) list.getAdapter(), text, text.getAbsoluteAdapterPosition());
}else{
notifyItemChanged(itemID, TextStatusDisplayItem.class);
}
if(isInstanceAkkoma())
return;
SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
if(spoiler!=null){
spoiler.rebind();
}
MediaGridStatusDisplayItem.Holder media=findHolderOfType(itemID, MediaGridStatusDisplayItem.Holder.class);
if (media!=null) {
media.rebind();
}
PreviewlessMediaGridStatusDisplayItem.Holder previewLessMedia=findHolderOfType(itemID, PreviewlessMediaGridStatusDisplayItem.Holder.class);
if (previewLessMedia!=null) {
previewLessMedia.rebind();
}
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item){
item.rebind();
}
}
}
public void rebuildAllDisplayItems(){ public void rebuildAllDisplayItems(){
displayItems.clear(); displayItems.clear();
for(T item:data){ for(T item:data){
@ -1171,26 +785,6 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
} }
public void maybeShowPreReplySheet(Status status, Runnable proceed){
Relationship rel=getRelationship(status.account.id);
if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, status.account, accountID) &&
!status.account.id.equals(AccountSessionManager.get(accountID).self.id) && rel!=null && !rel.followedBy && status.account.followingCount>=1){
new NonMutualPreReplySheet(getActivity(), notAgain->{
GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.NON_MUTUAL, notAgain ? null : status.account, accountID);
proceed.run();
}, status.account, accountID).show();
}else if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null) &&
status.createdAt.isBefore(Instant.now().minus(90, ChronoUnit.DAYS))){
new OldPostPreReplySheet(getActivity(), notAgain->{
if(notAgain)
GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null);
proceed.run();
}, status).show();
}else{
proceed.run();
}
}
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){} protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
@Override @Override
@ -1198,7 +792,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
if(getContext()==null) return; if(getContext()==null) return;
super.onDataLoaded(d, more); super.onDataLoaded(d, more);
// more available, but the page isn't even full yet? seems wrong, let's load some more // more available, but the page isn't even full yet? seems wrong, let's load some more
if(more && data.size() < itemsPerPage){ if(more && d.size() < itemsPerPage){
preloader.onScrolledToLastItem(); preloader.onScrolledToLastItem();
} }
} }
@ -1254,7 +848,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
{ {
dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface)); dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface));
dividerPaint.setStyle(Paint.Style.STROKE); dividerPaint.setStyle(Paint.Style.STROKE);
dividerPaint.setStrokeWidth(V.dp(1f)); dividerPaint.setStrokeWidth(V.dp(0.5f));
} }
@Override @Override

View file

@ -28,7 +28,7 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(HeaderPaginationList<Status> result){ public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return; if (getActivity() == null) return;
if(result.nextPageUri!=null) if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id"); nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else else

View file

@ -7,9 +7,6 @@ import static org.joinmastodon.android.api.requests.statuses.CreateStatus.getDra
import android.Manifest; import android.Manifest;
import android.animation.ObjectAnimator; import android.animation.ObjectAnimator;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.DatePickerDialog; import android.app.DatePickerDialog;
@ -65,7 +62,6 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
import org.joinmastodon.android.E; import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.TweakedFileProvider;
import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.DeleteStatus; import org.joinmastodon.android.api.requests.statuses.DeleteStatus;
@ -79,7 +75,7 @@ import org.joinmastodon.android.events.ScheduledStatusDeletedEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.fragments.account_list.AccountSearchFragment; import org.joinmastodon.android.fragments.account_list.ComposeAccountSearchFragment;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.Emoji;
@ -99,7 +95,7 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan; import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.utils.Tracking; import org.joinmastodon.android.utils.FileProvider;
import org.joinmastodon.android.utils.TransferSpeedTracker; import org.joinmastodon.android.utils.TransferSpeedTracker;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewcontrollers.ComposeAutocompleteViewController; import org.joinmastodon.android.ui.viewcontrollers.ComposeAutocompleteViewController;
@ -127,11 +123,11 @@ import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle; import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -139,14 +135,12 @@ import java.util.stream.Collectors;
import me.grishka.appkit.Nav; import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.CustomTransitionsFragment;
import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V; import me.grishka.appkit.utils.V;
public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID, CustomTransitionsFragment { public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, HasAccountID {
private static final int MEDIA_RESULT=717; private static final int MEDIA_RESULT=717;
public static final int IMAGE_DESCRIPTION_RESULT=363; public static final int IMAGE_DESCRIPTION_RESULT=363;
@ -171,7 +165,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public LinearLayout mainLayout; public LinearLayout mainLayout;
private SizeListenerLinearLayout contentView; private SizeListenerLinearLayout contentView;
private TextView selfName, selfUsername, selfExtraText, extraText; private TextView selfName, selfUsername, selfExtraText, extraText, pronouns;
private ImageView selfAvatar; private ImageView selfAvatar;
private Account self; private Account self;
private String instanceDomain; private String instanceDomain;
@ -182,7 +176,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private int charCount, charLimit, trimmedCharCount; private int charCount, charLimit, trimmedCharCount;
private Button publishButton, languageButton, scheduleTimeBtn; private Button publishButton, languageButton, scheduleTimeBtn;
private PopupMenu contentTypePopup, visibilityPopup, draftOptionsPopup; private PopupMenu languagePopup, contentTypePopup, visibilityPopup, draftOptionsPopup;
private ImageButton publishButtonRelocated, mediaBtn, pollBtn, emojiBtn, spoilerBtn, draftsBtn, scheduleDraftDismiss, contentTypeBtn; private ImageButton publishButtonRelocated, mediaBtn, pollBtn, emojiBtn, spoilerBtn, draftsBtn, scheduleDraftDismiss, contentTypeBtn;
private View sensitiveBtn; private View sensitiveBtn;
private TextView replyText; private TextView replyText;
@ -221,7 +215,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public Instance instance; public Instance instance;
public Status editingStatus; public Status editingStatus;
public ScheduledStatus scheduledStatus; private ScheduledStatus scheduledStatus;
private boolean redraftStatus; private boolean redraftStatus;
private Uri photoUri; private Uri photoUri;
@ -318,7 +312,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override @Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
creatingView=true; creatingView=true;
emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), accountID, customEmojis, instanceDomain); emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain, getAccountID());
emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){ emojiKeyboard.setListener(new CustomEmojiPopupKeyboard.Listener(){
@Override @Override
public void onEmojiSelected(Emoji emoji){ public void onEmojiSelected(Emoji emoji){
@ -370,7 +364,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
selfUsername=view.findViewById(R.id.self_username); selfUsername=view.findViewById(R.id.self_username);
selfAvatar=view.findViewById(R.id.self_avatar); selfAvatar=view.findViewById(R.id.self_avatar);
selfExtraText=view.findViewById(R.id.self_extra_text); selfExtraText=view.findViewById(R.id.self_extra_text);
HtmlParser.setTextWithCustomEmoji(selfName, self.getDisplayName(), self.emojis); HtmlParser.setTextWithCustomEmoji(selfName, self.displayName, self.emojis);
selfUsername.setText('@'+self.username+'@'+instanceDomain); selfUsername.setText('@'+self.username+'@'+instanceDomain);
if(self.avatar!=null) if(self.avatar!=null)
ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar)); ViewImageLoader.load(selfAvatar, null, new UrlImageLoaderRequest(self.avatar));
@ -471,7 +465,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
hasSpoiler=true; hasSpoiler=true;
spoilerWrap.setVisibility(View.VISIBLE); spoilerWrap.setVisibility(View.VISIBLE);
spoilerBtn.setSelected(true); spoilerBtn.setSelected(true);
}else if(editingStatus!=null && editingStatus.hasSpoiler()){ }else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){
hasSpoiler=true; hasSpoiler=true;
spoilerWrap.setVisibility(View.VISIBLE); spoilerWrap.setVisibility(View.VISIBLE);
spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText)); spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText));
@ -513,8 +507,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
int typeIndex=contentType.ordinal(); int typeIndex=contentType.ordinal();
if (contentTypePopup.getMenu().findItem(typeIndex) != null) contentTypePopup.getMenu().findItem(typeIndex).setChecked(true);
contentTypePopup.getMenu().findItem(typeIndex).setChecked(true);
contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal()); contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal());
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
@ -534,7 +527,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void onLaunchAccountSearch(){ public void onLaunchAccountSearch(){
Bundle args=new Bundle(); Bundle args=new Bundle();
args.putString("account", accountID); args.putString("account", accountID);
Nav.goForResult(getActivity(), AccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this); Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this);
} }
}); });
View autocompleteView=autocompleteViewController.getView(); View autocompleteView=autocompleteViewController.getView();
@ -692,6 +685,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}); });
View originalPost=view.findViewById(R.id.original_post); View originalPost=view.findViewById(R.id.original_post);
extraText=view.findViewById(R.id.extra_text); extraText=view.findViewById(R.id.extra_text);
pronouns=view.findViewById(R.id.pronouns);
originalPost.setVisibility(View.VISIBLE); originalPost.setVisibility(View.VISIBLE);
originalPost.setOnClickListener(v->{ originalPost.setOnClickListener(v->{
Bundle args=new Bundle(); Bundle args=new Bundle();
@ -731,7 +725,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
moreBtn.setBackground(null); moreBtn.setBackground(null);
TextView name = view.findViewById(R.id.name); TextView name = view.findViewById(R.id.name);
name.setText(HtmlParser.parseCustomEmoji(status.account.getDisplayName(), status.account.emojis)); name.setText(HtmlParser.parseCustomEmoji(status.account.displayName, status.account.emojis));
UiUtils.loadCustomEmojiInTextView(name); UiUtils.loadCustomEmojiInTextView(name);
String time = status==null || status.editedAt==null String time = status==null || status.editedAt==null
@ -743,7 +737,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
view.findViewById(R.id.time).setVisibility(time==null ? View.GONE : View.VISIBLE); view.findViewById(R.id.time).setVisibility(time==null ? View.GONE : View.VISIBLE);
if(time!=null) ((TextView) view.findViewById(R.id.time)).setText(time); if(time!=null) ((TextView) view.findViewById(R.id.time)).setText(time);
if (status.hasSpoiler()) { if (status.spoilerText != null && !status.spoilerText.isBlank()) {
TextView replyToSpoiler = view.findViewById(R.id.reply_to_spoiler); TextView replyToSpoiler = view.findViewById(R.id.reply_to_spoiler);
replyToSpoiler.setVisibility(View.VISIBLE); replyToSpoiler.setVisibility(View.VISIBLE);
replyToSpoiler.setText(status.spoilerText); replyToSpoiler.setText(status.spoilerText);
@ -765,8 +759,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); .setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16)));
} }
replyText.setText(HtmlParser.parseCustomEmoji(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.getDisplayName()), status.account.emojis)); replyText.setText(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.displayName));
UiUtils.loadCustomEmojiInTextView(replyText);
int visibilityNameRes = switch (status.visibility) { int visibilityNameRes = switch (status.visibility) {
case PUBLIC -> R.string.visibility_public; case PUBLIC -> R.string.visibility_public;
case UNLISTED -> R.string.sk_visibility_unlisted; case UNLISTED -> R.string.sk_visibility_unlisted;
@ -774,7 +767,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
case DIRECT -> R.string.visibility_private; case DIRECT -> R.string.visibility_private;
case LOCAL -> R.string.sk_local_only; case LOCAL -> R.string.sk_local_only;
}; };
replyText.setContentDescription(getString(R.string.in_reply_to, status.account.getDisplayName()) + ", " + getString(visibilityNameRes)); replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ", " + getString(visibilityNameRes));
replyText.setOnClickListener(v->{ replyText.setOnClickListener(v->{
scrollView.smoothScrollTo(0, 0); scrollView.smoothScrollTo(0, 0);
}); });
@ -787,7 +780,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id;
if(!status.account.id.equals(ownID)) if(!status.account.id.equals(ownID))
mentions.add('@'+status.account.acct); mentions.add('@'+status.account.acct);
if(GlobalUserPreferences.mentionRebloggerAutomatically && status.rebloggedBy != null && !status.rebloggedBy.id.equals(ownID)) if(status.rebloggedBy != null && GlobalUserPreferences.mentionRebloggerAutomatically)
mentions.add('@'+status.rebloggedBy.acct); mentions.add('@'+status.rebloggedBy.acct);
for(Mention mention:status.mentions){ for(Mention mention:status.mentions){
if(mention.id.equals(ownID)) if(mention.id.equals(ownID))
@ -808,7 +801,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
String prefix = (GlobalUserPreferences.prefixReplies == ALWAYS String prefix = (GlobalUserPreferences.prefixReplies == ALWAYS
|| (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(status.account.id))) || (GlobalUserPreferences.prefixReplies == TO_OTHERS && !ownID.equals(status.account.id)))
&& !status.spoilerText.startsWith("re: ") ? "re: " : ""; && !status.spoilerText.startsWith("re: ") ? "re: " : "";
spoilerEdit.setText(prefix + status.spoilerText); spoilerEdit.setText(prefix + replyTo.spoilerText);
spoilerBtn.setSelected(true); spoilerBtn.setSelected(true);
} }
if (status.language != null && !status.language.isEmpty()) setPostLanguage(status.language); if (status.language != null && !status.language.isEmpty()) setPostLanguage(status.language);
@ -869,7 +862,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
} }
@SuppressLint("ClickableViewAccessibility")
@Override @Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu); inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu);
@ -897,22 +889,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
charCounter.setText(String.valueOf(charLimit)); charCounter.setText(String.valueOf(charLimit));
} }
// draftsBtn=wrap.findViewById(R.id.drafts_btn); // draftsBtn = wrap.findViewById(R.id.drafts_btn);
draftOptionsPopup=new PopupMenu(getContext(), draftsBtn); draftOptionsPopup = new PopupMenu(getContext(), draftsBtn);
draftOptionsPopup.inflate(R.menu.compose_more); draftOptionsPopup.inflate(R.menu.compose_more);
Menu draftOptionsMenu=draftOptionsPopup.getMenu(); draftMenuItem = draftOptionsPopup.getMenu().findItem(R.id.draft);
draftMenuItem=draftOptionsMenu.findItem(R.id.draft); undraftMenuItem = draftOptionsPopup.getMenu().findItem(R.id.undraft);
undraftMenuItem=draftOptionsMenu.findItem(R.id.undraft); scheduleMenuItem = draftOptionsPopup.getMenu().findItem(R.id.schedule);
scheduleMenuItem=draftOptionsMenu.findItem(R.id.schedule); unscheduleMenuItem = draftOptionsPopup.getMenu().findItem(R.id.unschedule);
unscheduleMenuItem=draftOptionsMenu.findItem(R.id.unschedule);
draftOptionsMenu.findItem(R.id.preview).setVisible(isInstanceAkkoma());
draftOptionsPopup.setOnMenuItemClickListener(i->{ draftOptionsPopup.setOnMenuItemClickListener(i->{
int id=i.getItemId(); int id = i.getItemId();
if(id==R.id.draft) updateScheduledAt(getDraftInstant()); if (id == R.id.draft) updateScheduledAt(getDraftInstant());
else if(id==R.id.schedule) pickScheduledDateTime(); else if (id == R.id.schedule) pickScheduledDateTime();
else if(id==R.id.unschedule || id==R.id.undraft) updateScheduledAt(null); else if (id == R.id.unschedule || id == R.id.undraft) updateScheduledAt(null);
else if(id==R.id.drafts) navigateToUnsentPosts(); else navigateToUnsentPosts();
else if(id==R.id.preview) publish(true);
return true; return true;
}); });
UiUtils.enablePopupMenuIcons(getContext(), draftOptionsPopup); UiUtils.enablePopupMenuIcons(getContext(), draftOptionsPopup);
@ -920,40 +909,23 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
languageButton = wrap.findViewById(R.id.language_btn); languageButton = wrap.findViewById(R.id.language_btn);
languageButton.setOnClickListener(v->showLanguageAlert()); languageButton.setOnClickListener(v->showLanguageAlert());
languageButton.setOnLongClickListener(v->{
if(!getLocalPrefs().bottomEncoding){
getLocalPrefs().bottomEncoding=true;
getLocalPrefs().save();
}
return false;
});
if(instance.isIceshrimpJs())
languageButton.setVisibility(View.GONE); // hide language selector on Iceshrimp-JS because the feature is not supported
if (!GlobalUserPreferences.relocatePublishButton) if(GlobalUserPreferences.relocatePublishButton){
publishButton.post(()->publishButton.setMinimumWidth(publishButton.getWidth())); publishButtonRelocated.setOnClickListener(v -> {
if(GlobalUserPreferences.altTextReminders && editingStatus==null)
checkAltTextsAndPublish();
else
publish();
});
} else {
publishButton.setOnClickListener(v -> {
if(GlobalUserPreferences.altTextReminders && editingStatus==null)
checkAltTextsAndPublish();
else
publish();
});
}
(GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setOnClickListener(v->{
Consumer<Boolean> draftCheckComplete=(isDraft)->{
if(GlobalUserPreferences.altTextReminders && !isDraft) checkAltTextsAndPublish();
else publish();
};
boolean isAlreadyDraft=scheduledAt!=null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT);
if(editingStatus!=null && scheduledAt!=null && isAlreadyDraft) {
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_save_draft)
.setMessage(R.string.sk_save_draft_message)
.setPositiveButton(R.string.save, (d, w)->draftCheckComplete.accept(isAlreadyDraft))
.setNegativeButton(R.string.publish, (d, w)->{
updateScheduledAt(null);
draftCheckComplete.accept(false);
})
.show();
}else{
draftCheckComplete.accept(isAlreadyDraft);
}
});
draftsBtn.setOnClickListener(v-> draftOptionsPopup.show()); draftsBtn.setOnClickListener(v-> draftOptionsPopup.show());
draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener()); draftsBtn.setOnTouchListener(draftOptionsPopup.getDragToOpenListener());
updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null);
@ -964,8 +936,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
? languageResolver.fromOrFallback(prefs.postingDefaultLanguage) ? languageResolver.fromOrFallback(prefs.postingDefaultLanguage)
: languageResolver.getDefault()); : languageResolver.getDefault());
if(isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE); if (isInstancePixelfed()) spoilerBtn.setVisibility(View.GONE);
if(isInstancePixelfed() || (editingStatus!=null && !redraftStatus)) { if (isInstancePixelfed() || (editingStatus != null && scheduledStatus == null)) {
// editing an already published post // editing an already published post
draftsBtn.setVisibility(View.GONE); draftsBtn.setVisibility(View.GONE);
} }
@ -1055,12 +1027,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void updatePublishButtonState(){ public void updatePublishButtonState(){
uuid=null; uuid=null;
if(GlobalUserPreferences.relocatePublishButton && publishButtonRelocated != null){ if(GlobalUserPreferences.relocatePublishButton && publishButtonRelocated != null){
publishButtonRelocated.setEnabled(((!isInstancePixelfed() || replyTo != null) || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); publishButtonRelocated.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1));
} }
if(publishButton==null) if(publishButton==null)
return; return;
publishButton.setEnabled(((!isInstancePixelfed() || replyTo != null) || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1));
} }
private void onCustomEmojiClick(Emoji emoji){ private void onCustomEmojiClick(Emoji emoji){
@ -1096,7 +1068,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override @Override
protected int getNavigationIconDrawableResource(){ protected int getNavigationIconDrawableResource(){
return R.drawable.ic_fluent_dismiss_24_regular; return R.drawable.ic_baseline_close_24;
} }
@Override @Override
@ -1155,10 +1127,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
private void publish(){ private void publish(){
publish(false);
}
private void publish(boolean preview){
sendingOverlay=new View(getActivity()); sendingOverlay=new View(getActivity());
WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams(); WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams();
overlayParams.type=WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; overlayParams.type=WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
@ -1169,27 +1137,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
overlayParams.token=mainEditText.getWindowToken(); overlayParams.token=mainEditText.getWindowToken();
wm.addView(sendingOverlay, overlayParams); wm.addView(sendingOverlay, overlayParams);
(GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); if(GlobalUserPreferences.relocatePublishButton){
publishButtonRelocated.setEnabled(false);
} else {
publishButton.setEnabled(false);
}
V.setVisibilityAnimated(sendProgress, View.VISIBLE); V.setVisibilityAnimated(sendProgress, View.VISIBLE);
mediaViewController.saveAltTextsBeforePublishing( mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError);
()->actuallyPublish(preview),
this::handlePublishError);
} }
private void actuallyPublish(boolean preview){ private void actuallyPublish(){
actuallyPublish(false);
}
private void actuallyPublish(boolean force){
String text=mainEditText.getText().toString(); String text=mainEditText.getText().toString();
if(GlobalUserPreferences.removeTrackingParams)
text=Tracking.cleanUrlsInText(text);
CreateStatus.Request req=new CreateStatus.Request(); CreateStatus.Request req=new CreateStatus.Request();
if("bottom".equals(postLang.encoding)){ if ("bottom".equals(postLang.encoding)) {
text=new StatusTextEncoder(Bottom::encode).encode(text); text = new StatusTextEncoder(Bottom::encode).encode(text);
req.spoilerText="bottom-encoded emoji spam"; req.spoilerText = "bottom-encoded emoji spam";
} }
if(localOnly && if (localOnly &&
AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance && AccountSessionManager.get(accountID).getLocalPreferences().glitchInstance &&
!GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()){ !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) {
text+=" "+GLITCH_LOCAL_ONLY_SUFFIX; text += " " + GLITCH_LOCAL_ONLY_SUFFIX;
} }
req.status=text; req.status=text;
req.localOnly=localOnly; req.localOnly=localOnly;
@ -1197,12 +1169,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
req.sensitive=sensitive; req.sensitive=sensitive;
req.contentType=contentType==ContentType.UNSPECIFIED ? null : contentType; req.contentType=contentType==ContentType.UNSPECIFIED ? null : contentType;
req.scheduledAt=scheduledAt; req.scheduledAt=scheduledAt;
req.preview=preview;
if(!mediaViewController.isEmpty()){ if(!mediaViewController.isEmpty()){
req.mediaIds=mediaViewController.getAttachmentIDs(); req.mediaIds=mediaViewController.getAttachmentIDs();
if(editingStatus != null){ }
req.mediaAttributes=mediaViewController.getAttachmentAttributes(); // ask whether to publish now when editing an existing draft
} if (!force && editingStatus != null && scheduledAt != null && scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) {
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_save_draft)
.setMessage(R.string.sk_save_draft_message)
.setPositiveButton(R.string.save, (d, w) -> actuallyPublish(true))
.setNegativeButton(R.string.publish, (d, w) -> {
updateScheduledAt(null);
actuallyPublish();
})
.show();
return;
} }
if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){ if(replyTo!=null || (editingStatus != null && editingStatus.inReplyToId!=null)){
req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id; req.inReplyToId=editingStatus!=null ? editingStatus.inReplyToId : replyTo.id;
@ -1225,12 +1206,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
Callback<Status> resCallback=new Callback<>(){ Callback<Status> resCallback=new Callback<>(){
@Override @Override
public void onSuccess(Status result){ public void onSuccess(Status result){
if(preview){ maybeDeleteScheduledPost(() -> {
openPreview(result);
return;
}
maybeDeleteScheduledPost(()->{
wm.removeView(sendingOverlay); wm.removeView(sendingOverlay);
sendingOverlay=null; sendingOverlay=null;
if(editingStatus==null || redraftStatus){ if(editingStatus==null || redraftStatus){
@ -1252,10 +1228,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
E.post(new StatusUpdatedEvent(editedStatus)); E.post(new StatusUpdatedEvent(editedStatus));
} }
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isStateSaved()){ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isStateSaved()) {
Nav.finish(ComposeFragment.this); Nav.finish(ComposeFragment.this);
} }
if(getArguments().getBoolean("navigateToStatus", false)){ if (getArguments().getBoolean("navigateToStatus", false)) {
Bundle args=new Bundle(); Bundle args=new Bundle();
args.putString("account", accountID); args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(result)); args.putParcelable("status", Parcels.wrap(result));
@ -1271,11 +1247,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
}; };
if(editingStatus!=null && !redraftStatus && !preview){ if(editingStatus!=null && !redraftStatus){
new EditStatus(req, editingStatus.id) new EditStatus(req, editingStatus.id)
.setCallback(resCallback) .setCallback(resCallback)
.exec(accountID); .exec(accountID);
}else if(req.scheduledAt == null || preview){ }else if(req.scheduledAt == null){
new CreateStatus(req, uuid) new CreateStatus(req, uuid)
.setCallback(resCallback) .setCallback(resCallback)
.exec(accountID); .exec(accountID);
@ -1305,7 +1281,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
.setPositiveButton(R.string.ok, (a, b)->{}) .setPositiveButton(R.string.ok, (a, b)->{})
.show(); .show();
handlePublishError(null); handlePublishError(null);
(GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); publishButton.setEnabled(false);
} }
if (replyTo == null) updateRecentLanguages(); if (replyTo == null) updateRecentLanguages();
@ -1315,7 +1291,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
wm.removeView(sendingOverlay); wm.removeView(sendingOverlay);
sendingOverlay=null; sendingOverlay=null;
V.setVisibilityAnimated(sendProgress, View.GONE); V.setVisibilityAnimated(sendProgress, View.GONE);
(GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true); publishButton.setEnabled(true);
if(error instanceof MastodonErrorResponse me){ if(error instanceof MastodonErrorResponse me){
new M3AlertDialogBuilder(getActivity()) new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.post_failed) .setTitle(R.string.post_failed)
@ -1328,25 +1304,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
} }
private void openPreview(Status result){
result.preview=true;
wm.removeView(sendingOverlay);
sendingOverlay=null;
(GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true);
V.setVisibilityAnimated(sendProgress, View.GONE);
InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class);
imm.hideSoftInputFromWindow(contentView.getWindowToken(), 0);
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(result));
if(replyTo!=null){
args.putParcelable("inReplyTo", Parcels.wrap(replyTo));
args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo.account));
}
Nav.go(getActivity(), ThreadFragment.class, args);
}
private void updateRecentLanguages() { private void updateRecentLanguages() {
if (postLang == null || postLang.language == null) return; if (postLang == null || postLang.language == null) return;
String language = postLang.language.getLanguage(); String language = postLang.language.getLanguage();
@ -1422,20 +1379,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
private void confirmDiscardDraftAndFinish(){ private void confirmDiscardDraftAndFinish(){
boolean attachmentsPending=mediaViewController.areAnyAttachmentsNotDone(); boolean attachmentsPending = mediaViewController.areAnyAttachmentsNotDone();
if(attachmentsPending) new M3AlertDialogBuilder(getActivity()) if (attachmentsPending) new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_unfinished_attachments) .setTitle(R.string.sk_unfinished_attachments)
.setMessage(R.string.sk_unfinished_attachments_message) .setMessage(R.string.sk_unfinished_attachments_message)
.setPositiveButton(R.string.ok, (d, w)->{}) .setPositiveButton(R.string.edit, (d, w) -> {})
.setNegativeButton(R.string.discard, (d, w)->Nav.finish(this)) .setNegativeButton(R.string.discard, (d, w) -> Nav.finish(this))
.show(); .show();
else new M3AlertDialogBuilder(getActivity()) else new M3AlertDialogBuilder(getActivity())
.setTitle(editingStatus!=null ? R.string.sk_confirm_save_changes : R.string.sk_confirm_save_draft) .setTitle(editingStatus != null ? R.string.sk_confirm_save_changes : R.string.sk_confirm_save_draft)
.setPositiveButton(R.string.save, (d, w)->{ .setPositiveButton(R.string.save, (d, w) -> {
updateScheduledAt(scheduledAt==null ? getDraftInstant() : scheduledAt); updateScheduledAt(scheduledAt == null ? getDraftInstant() : scheduledAt);
publish(); publish();
}) })
.setNegativeButton(R.string.discard, (d, w)->Nav.finish(this)) .setNegativeButton(R.string.discard, (d, w) -> Nav.finish(this))
.show(); .show();
} }
@ -1453,8 +1410,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
boolean usePhotoPicker=photoPicker && UiUtils.isPhotoPickerAvailable(); boolean usePhotoPicker=photoPicker && UiUtils.isPhotoPickerAvailable();
if(usePhotoPicker){ if(usePhotoPicker){
intent=new Intent(MediaStore.ACTION_PICK_IMAGES); intent=new Intent(MediaStore.ACTION_PICK_IMAGES);
if(mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount()>1) intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount());
intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount());
}else{ }else{
intent=new Intent(Intent.ACTION_GET_CONTENT); intent=new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
@ -1511,7 +1467,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void openCamera() throws IOException { private void openCamera() throws IOException {
if (getContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { if (getContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
File photoFile = File.createTempFile("img", ".jpg"); File photoFile = File.createTempFile("img", ".jpg");
photoUri = UiUtils.getFileProviderUri(getContext(), photoFile); photoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", photoFile);
Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
@ -1562,7 +1518,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void updateSensitive() { public void updateSensitive() {
sensitiveBtn.setVisibility(View.GONE); sensitiveBtn.setVisibility(View.GONE);
if (!mediaViewController.isEmpty()) sensitiveBtn.setVisibility(View.VISIBLE); if (!mediaViewController.isEmpty() && !hasSpoiler) sensitiveBtn.setVisibility(View.VISIBLE);
if (mediaViewController.isEmpty()) sensitive = false; if (mediaViewController.isEmpty()) sensitive = false;
} }
@ -1573,8 +1529,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
.withMinute(0); .withMinute(0);
new DatePickerDialog(getActivity(), (datePicker, year, arrayMonth, dayOfMonth) -> { new DatePickerDialog(getActivity(), (datePicker, year, arrayMonth, dayOfMonth) -> {
new TimePickerDialog(getActivity(), (timePicker, hour, minute) -> { new TimePickerDialog(getActivity(), (timePicker, hour, minute) -> {
LocalDateTime at=LocalDateTime.of(year, arrayMonth + 1, dayOfMonth, hour, minute); updateScheduledAt(LocalDateTime.of(year, arrayMonth + 1, dayOfMonth, hour, minute)
updateScheduledAt(at.toInstant(ZoneId.systemDefault().getRules().getOffset(at))); .toInstant(OffsetDateTime.now().getOffset()));
}, soon.getHour(), soon.getMinute(), DateFormat.is24HourFormat(getActivity())).show(); }, soon.getHour(), soon.getMinute(), DateFormat.is24HourFormat(getActivity())).show();
}, soon.getYear(), soon.getMonthValue() - 1, soon.getDayOfMonth()).show(); }, soon.getYear(), soon.getMonthValue() - 1, soon.getDayOfMonth()).show();
} }
@ -1633,15 +1589,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} else { } else {
draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_regular : R.drawable.ic_fluent_clock_20_regular)); draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_regular : R.drawable.ic_fluent_clock_20_regular));
if(GlobalUserPreferences.relocatePublishButton){ if(GlobalUserPreferences.relocatePublishButton){
publishButtonRelocated.setImageResource(R.drawable.ic_fluent_send_24_regular); publishButtonRelocated.setImageResource(R.drawable.ic_fluent_send_24_selector);
} }
resetPublishButtonText(); resetPublishButtonText();
} }
} }
private void updateHeaders() { private void updateHeaders() {
UiUtils.setExtraTextInfo(getContext(), selfExtraText, false, false, localOnly, null); UiUtils.setExtraTextInfo(getContext(), selfExtraText, null, false, false, localOnly || statusVisibility==StatusPrivacy.LOCAL, null);
if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, true, false, replyTo.localOnly || replyTo.visibility==StatusPrivacy.LOCAL, replyTo.account); if (replyTo != null) UiUtils.setExtraTextInfo(getContext(), extraText, pronouns, true, false, replyTo.localOnly || replyTo.visibility==StatusPrivacy.LOCAL, replyTo.account);
} }
private void buildVisibilityPopup(View v){ private void buildVisibilityPopup(View v){
@ -1669,7 +1625,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
} }
UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup); UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) m.setGroupDividerEnabled(true); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P) m.setGroupDividerEnabled(true);
visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){
@Override @Override
public boolean onMenuItemClick(MenuItem item){ public boolean onMenuItemClick(MenuItem item){
@ -1713,7 +1669,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
} }
contentTypePopup.setOnMenuItemClickListener(i->{ contentTypePopup.setOnMenuItemClickListener(i->{
uuid=null;
int index=i.getItemId(); int index=i.getItemId();
contentType=ContentType.values()[index]; contentType=ContentType.values()[index];
btn.setSelected(index!=ContentType.UNSPECIFIED.ordinal() && index!=ContentType.PLAIN.ordinal()); btn.setSelected(index!=ContentType.UNSPECIFIED.ordinal() && index!=ContentType.PLAIN.ordinal());
@ -1818,26 +1773,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"}; return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"};
} }
private String sanitizeMediaDescription(String description){
if(description == null){
return null;
}
// The Gboard android keyboard attaches this text whenever the user
// pastes something from the keyboard's suggestion bar.
// Due to different end user locales, the exact text may vary, but at
// least in version 13.4.08, all of the translations contained the
// string "Gboard".
if (description.contains("Gboard")){
return null;
}
return description;
}
@Override @Override
public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){
description = sanitizeMediaDescription(description);
return mediaViewController.addMediaAttachment(uri, description); return mediaViewController.addMediaAttachment(uri, description);
} }
@ -1880,8 +1817,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
Editable e=mainEditText.getText(); Editable e=mainEditText.getText();
int start=e.getSpanStart(currentAutocompleteSpan); int start=e.getSpanStart(currentAutocompleteSpan);
int end=e.getSpanEnd(currentAutocompleteSpan); int end=e.getSpanEnd(currentAutocompleteSpan);
if(start==-1 || end==-1)
return;
e.replace(start, end, text+" "); e.replace(start, end, text+" ");
finishAutocomplete(); finishAutocomplete();
InputConnection conn=mainEditText.getCurrentInputConnection(); InputConnection conn=mainEditText.getCurrentInputConnection();
@ -1950,35 +1885,4 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
languageButton.setText(opt.language.getLanguageName()); languageButton.setText(opt.language.getLanguageName());
languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName())); languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, opt.language.getDefaultName()));
} }
@Override
public Animator onCreateEnterTransition(View prev, View container){
AnimatorSet anim=new AnimatorSet();
if(getArguments().getBoolean("fromThreadFragment")){
anim.playTogether(
ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f),
ObjectAnimator.ofFloat(container, View.TRANSLATION_Y, V.dp(200), 0)
);
}else{
anim.playTogether(
ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f),
ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100), 0)
);
}
anim.setDuration(300);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
return anim;
}
@Override
public Animator onCreateExitTransition(View prev, View container){
AnimatorSet anim=new AnimatorSet();
anim.playTogether(
ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100)),
ObjectAnimator.ofFloat(container, View.ALPHA, 0)
);
anim.setDuration(200);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
return anim;
}
} }

View file

@ -7,7 +7,10 @@ import android.graphics.Rect;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.style.BulletSpan;
import android.util.Log; import android.util.Log;
import android.view.ContextThemeWrapper; import android.view.ContextThemeWrapper;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -23,12 +26,12 @@ import android.widget.ImageView;
import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.utils.ColorPalette; import org.joinmastodon.android.ui.utils.ColorPalette;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import java.util.Collections; import java.util.Collections;
@ -51,17 +54,16 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
@Override @Override
public void onCreate(Bundle savedInstanceState){ public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
setHasOptionsMenu(true); setHasOptionsMenu(true);
} }
@Override @Override
public void onAttach(Activity activity){ public void onAttach(Activity activity){
super.onAttach(activity); super.onAttach(activity);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark); themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
ColorPalette.palettes.get(AccountSessionManager.get(accountID).getLocalPreferences().getCurrentColor()) ColorPalette.palettes.get(GlobalUserPreferences.color).apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK);
.apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK);
setTitle(R.string.add_alt_text); setTitle(R.string.add_alt_text);
} }
@ -132,9 +134,20 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
@Override @Override
public boolean onOptionsItemSelected(MenuItem item){ public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.help){ if(item.getItemId()==R.id.help){
SpannableStringBuilder msg=new SpannableStringBuilder(getText(R.string.alt_text_help));
BulletSpan[] spans=msg.getSpans(0, msg.length(), BulletSpan.class);
for(BulletSpan span:spans){
BulletSpan betterSpan;
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.Q)
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface));
else
betterSpan=new BulletSpan(V.dp(10), UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnSurface), V.dp(1.5f));
msg.setSpan(betterSpan, msg.getSpanStart(span), msg.getSpanEnd(span), msg.getSpanFlags(span));
msg.removeSpan(span);
}
new M3AlertDialogBuilder(themeWrapper) new M3AlertDialogBuilder(themeWrapper)
.setTitle(R.string.what_is_alt_text) .setTitle(R.string.what_is_alt_text)
.setMessage(UiUtils.fixBulletListInString(themeWrapper, R.string.alt_text_help)) .setMessage(msg)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show(); .show();
} }
@ -171,7 +184,7 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
fakeAttachment.meta.width=width; fakeAttachment.meta.width=width;
fakeAttachment.meta.height=height; fakeAttachment.meta.height=height;
photoViewer=new PhotoViewer(getActivity(), Collections.singletonList(fakeAttachment), 0, null, accountID, new PhotoViewer.Listener(){ photoViewer=new PhotoViewer(getActivity(), Collections.singletonList(fakeAttachment), 0, new PhotoViewer.Listener(){
@Override @Override
public void setPhotoViewVisibility(int index, boolean visible){ public void setPhotoViewVisibility(int index, boolean visible){
image.setAlpha(visible ? 1f : 0f); image.setAlpha(visible ? 1f : 0f);

View file

@ -1,330 +0,0 @@
package org.joinmastodon.android.fragments;
import android.content.res.TypedArray;
import android.net.Uri;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.events.FinishListCreationFragmentEvent;
import org.joinmastodon.android.fragments.account_list.AddNewListMembersFragment;
import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.ui.views.CurlyArrowEmptyView;
import org.parceler.Parcels;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.fragments.WindowInsetsAwareFragment;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class CreateListAddMembersFragment extends BaseAccountListFragment implements OnBackPressedListener, AddNewListMembersFragment.Listener{
private FollowList followList;
private Button nextButton;
private View buttonBar;
private FragmentRootLinearLayout rootView;
private FrameLayout searchFragmentContainer;
private FrameLayout fragmentContentWrap;
private AddNewListMembersFragment searchFragment;
private WindowInsets lastInsets;
private boolean dismissingSearchFragment;
private HashSet<String> accountIDsInList=new HashSet<>();
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_list_members);
setSubtitle(getString(R.string.step_x_of_y, 2, 2));
setLayout(R.layout.fragment_login);
setEmptyText(R.string.list_no_members);
setHasOptionsMenu(true);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
if(savedInstanceState!=null || getArguments().getBoolean("needLoadMembers", false)){
loadData();
}else{
onDataLoaded(List.of());
}
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetListAccounts(followList.id, null, 0)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
for(Account acc:result)
accountIDsInList.add(acc.id);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()));
}
})
.exec(accountID);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
View view=super.onCreateView(inflater, container, savedInstanceState);
FrameLayout wrapper=new FrameLayout(getActivity());
wrapper.addView(view);
rootView=(FragmentRootLinearLayout) view;
fragmentContentWrap=wrapper;
return wrapper;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setText(R.string.done);
buttonBar=view.findViewById(R.id.button_bar);
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
lastInsets=insets;
if(searchFragment!=null)
searchFragment.onApplyWindowInsets(insets);
insets=UiUtils.applyBottomInsetToFixedView(buttonBar, insets);
rootView.dispatchApplyWindowInsets(insets);
}
@Override
protected List<View> getViewsForElevationEffect(){
return List.of(getToolbar(), buttonBar);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
MenuItem item=menu.add(R.string.add_list_member);
item.setIcon(R.drawable.ic_fluent_add_24_regular);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(searchFragmentContainer!=null)
return true;
searchFragmentContainer=new FrameLayout(getActivity());
searchFragmentContainer.setId(R.id.search_fragment);
fragmentContentWrap.addView(searchFragmentContainer);
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
args.putBoolean("_can_go_back", true);
searchFragment=new AddNewListMembersFragment(this);
searchFragment.setArguments(args);
getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit();
getChildFragmentManager().executePendingTransactions();
if(lastInsets!=null)
searchFragment.onApplyWindowInsets(lastInsets);
searchFragmentContainer.setTranslationX(V.dp(100));
searchFragmentContainer.setAlpha(0f);
searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
rootView.setVisibility(View.GONE);
}).start();
return true;
}
@Override
protected void initializeEmptyView(View contentView){
ViewStub emptyStub=contentView.findViewById(R.id.empty);
emptyStub.setLayoutResource(R.layout.empty_with_arrow);
super.initializeEmptyView(contentView);
TextView emptySecondary=contentView.findViewById(R.id.empty_text_secondary);
emptySecondary.setText(R.string.list_find_users);
CurlyArrowEmptyView arrowView=(CurlyArrowEmptyView) emptyView;
arrowView.setGravityAndOffsets(Gravity.TOP | Gravity.END, 24, 2);
}
@Override
protected void setStatusBarColor(int color){
rootView.setStatusBarColor(color);
}
@Override
protected void setNavigationBarColor(int color){
rootView.setNavigationBarColor(color);
}
private void dismissSearchFragment(){
if(searchFragment==null || dismissingSearchFragment)
return;
dismissingSearchFragment=true;
rootView.setVisibility(View.VISIBLE);
searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
getChildFragmentManager().beginTransaction().remove(searchFragment).commit();
getChildFragmentManager().executePendingTransactions();
fragmentContentWrap.removeView(searchFragmentContainer);
searchFragmentContainer=null;
searchFragment=null;
dismissingSearchFragment=false;
}).start();
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
}
private void onNextClick(View v){
E.post(new FinishListCreationFragmentEvent(accountID, followList.id));
Nav.finish(this);
}
@Override
public boolean onBackPressed(){
if(searchFragment!=null){
dismissSearchFragment();
return true;
}
return false;
}
@Override
public boolean isAccountInList(AccountViewModel account){
return accountIDsInList.contains(account.account.id);
}
@Override
public void addAccountToList(AccountViewModel account, Runnable onDone){
new AddAccountsToList(followList.id, Set.of(account.account.id))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
accountIDsInList.add(account.account.id);
if(onDone!=null)
onDone.run();
int i=0;
for(AccountViewModel acc:data){
if(acc.account.id.equals(account.account.id)){
list.getAdapter().notifyItemChanged(i);
return;
}
i++;
}
int pos=data.size();
data.add(account);
list.getAdapter().notifyItemInserted(pos);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.exec(accountID);
}
@Override
public void removeAccountAccountFromList(AccountViewModel account, Runnable onDone){
new RemoveAccountsFromList(followList.id, Set.of(account.account.id))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
accountIDsInList.remove(account.account.id);
if(onDone!=null)
onDone.run();
int i=0;
for(AccountViewModel acc:data){
if(acc.account.id.equals(account.account.id)){
list.getAdapter().notifyItemChanged(i);
return;
}
i++;
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.exec(accountID);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
holder.setStyle(AccountViewHolder.AccessoryType.CUSTOM_BUTTON, false);
holder.setOnLongClickListener(vh->false);
Button button=holder.getButton();
button.setPadding(V.dp(24), 0, V.dp(24), 0);
button.setMinimumWidth(0);
button.setMinWidth(0);
button.setOnClickListener(v->{
holder.setActionProgressVisible(true);
holder.itemView.setHasTransientState(true);
Runnable onDone=()->{
holder.setActionProgressVisible(false);
holder.itemView.setHasTransientState(false);
};
AccountViewModel account=holder.getItem();
if(isAccountInList(account)){
removeAccountAccountFromList(account, onDone);
}else{
addAccountToList(account, onDone);
}
});
}
@Override
protected void onBindViewHolder(AccountViewHolder holder){
Button button=holder.getButton();
int textRes, styleRes;
if(isAccountInList(holder.getItem())){
textRes=R.string.remove;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error;
}else{
textRes=R.string.add;
styleRes=R.style.Widget_Mastodon_M3_Button_Filled;
}
button.setText(textRes);
TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor});
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
@Override
protected void loadRelationships(List<AccountViewModel> accounts){
// no-op
}
@Override
public Uri getWebUri(Uri.Builder base){
// TODO this
return null;
}
}

View file

@ -1,149 +0,0 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.WindowInsets;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.CreateList;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FinishListCreationFragmentEvent;
import org.joinmastodon.android.events.ListCreatedEvent;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class CreateListFragment extends BaseEditListFragment{
private Button nextButton;
private View buttonBar;
private FollowList followList;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.create_list);
setSubtitle(getString(R.string.step_x_of_y, 1, 2));
setLayout(R.layout.fragment_login);
if(savedInstanceState!=null)
followList=Parcels.unwrap(savedInstanceState.getParcelable("list"));
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected int getNavigationIconDrawableResource(){
return R.drawable.ic_baseline_arrow_drop_down_18;
}
@Override
public boolean wantsCustomNavigationIcon(){
return true;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
nextButton=view.findViewById(R.id.btn_next);
nextButton.setOnClickListener(this::onNextClick);
nextButton.setText(R.string.create);
buttonBar=view.findViewById(R.id.button_bar);
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
}
@Override
protected List<View> getViewsForElevationEffect(){
return List.of(getToolbar(), buttonBar);
}
@Override
public void onSaveInstanceState(Bundle outState){
super.onSaveInstanceState(outState);
outState.putParcelable("list", Parcels.wrap(followList));
}
private void onNextClick(View v){
String title=titleEdit.getText().toString().trim();
if(TextUtils.isEmpty(title)){
titleEditLayout.setErrorState(getString(R.string.required_form_field_blank));
return;
}
if(followList==null){
new CreateList(title, getSelectedRepliesPolicy(), exclusiveItem.checked)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
followList=result;
proceed(false);
E.post(new ListCreatedEvent(accountID, result));
AccountSessionManager.get(accountID).getCacheController().addList(result);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}else if(!title.equals(followList.title) || getSelectedRepliesPolicy()!=followList.repliesPolicy || exclusiveItem.checked!=followList.exclusive){
new UpdateList(followList.id, title, getSelectedRepliesPolicy(), exclusiveItem.checked)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
followList=result;
proceed(true);
E.post(new ListUpdatedEvent(accountID, result));
AccountSessionManager.get(accountID).getCacheController().updateList(result);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}else{
proceed(true);
}
}
private void proceed(boolean needLoadMembers){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
args.putBoolean("needLoadMembers", needLoadMembers);
Nav.go(getActivity(), CreateListAddMembersFragment.class, args);
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
}
@Subscribe
public void onFinishListCreationFragment(FinishListCreationFragmentEvent ev){
if(ev.accountID.equals(accountID) && followList!=null && ev.listID.equals(followList.id)){
Nav.finish(this);
}
}
}

View file

@ -7,14 +7,16 @@ import android.view.MenuInflater;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.api.SimpleCallback;
@ -44,14 +46,14 @@ public class CustomLocalTimelineFragment extends PinnableStatusListFragment impl
@Override @Override
protected void doLoadData(int offset, int count){ protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null, getLocalPrefs().timelineReplyVisibility) currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(List<Status> result){ public void onSuccess(List<Status> result){
if(!result.isEmpty()) if(!result.isEmpty())
maxID=result.get(result.size()-1).id; maxID=result.get(result.size()-1).id;
if (getActivity() == null) return; if (getActivity() == null) return;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC); result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList());
result.stream().forEach(status -> { result.stream().forEach(status -> {
status.account.acct += "@"+domain; status.account.acct += "@"+domain;
status.mentions.forEach(mention -> mention.id = null); status.mentions.forEach(mention -> mention.id = null);
@ -80,15 +82,12 @@ public class CustomLocalTimelineFragment extends PinnableStatusListFragment impl
@Override @Override
protected FilterContext getFilterContext() { protected FilterContext getFilterContext() {
return FilterContext.PUBLIC; return null;
} }
@Override @Override
public Uri getWebUri(Uri.Builder base) { public Uri getWebUri(Uri.Builder base) {
return new Uri.Builder() return Uri.parse(domain);
.scheme("https")
.authority(domain)
.build();
} }
@Override @Override

View file

@ -0,0 +1,15 @@
package org.joinmastodon.android.fragments;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
public interface DomainDisplay {
default String getDomain(){
AccountSession session = AccountSessionManager.getInstance().getLastActiveAccount();
if (session != null)
return session.domain;
else
return "";
}
}

View file

@ -1,67 +0,0 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class EditListFragment extends BaseEditListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.edit_list);
loadMembers();
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
menu.add(R.string.delete_list);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.delete_list)
.setMessage(getString(R.string.delete_list_confirm, followList.title))
.setPositiveButton(R.string.delete, (dlg, which)->doDeleteList())
.setNegativeButton(R.string.cancel, null)
.show();
return true;
}
@Override
public void onDestroy(){
super.onDestroy();
String newTitle=titleEdit.getText().toString();
FollowList.RepliesPolicy newRepliesPolicy=getSelectedRepliesPolicy();
boolean newExclusive=exclusiveItem.checked;
if(!newTitle.equals(followList.title) || newRepliesPolicy!=followList.repliesPolicy || newExclusive!=followList.exclusive){
new UpdateList(followList.id, newTitle, newRepliesPolicy, newExclusive)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
AccountSessionManager.get(accountID).getCacheController().updateList(result);
E.post(new ListUpdatedEvent(accountID, result));
}
@Override
public void onError(ErrorResponse error){
// TODO handle errors somehow
}
})
.exec(accountID);
}
}
}

View file

@ -41,14 +41,17 @@ import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CustomLocalTimeline; import org.joinmastodon.android.model.CustomLocalTimeline;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TextInputFrameLayout;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -64,86 +67,86 @@ import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V; import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView; import me.grishka.appkit.views.UsableRecyclerView;
public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefinition> implements ScrollableToTop{ public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefinition> implements ScrollableToTop {
private String accountID; private String accountID;
private TimelinesAdapter adapter; private TimelinesAdapter adapter;
private final ItemTouchHelper itemTouchHelper; private final ItemTouchHelper itemTouchHelper;
private Menu optionsMenu; private Menu optionsMenu;
private boolean updated; private boolean updated;
private final Map<MenuItem, TimelineDefinition> timelineByMenuItem=new HashMap<>(); private final Map<MenuItem, TimelineDefinition> timelineByMenuItem = new HashMap<>();
private final List<FollowList> followLists =new ArrayList<>(); private final List<ListTimeline> listTimelines = new ArrayList<>();
private final List<Hashtag> hashtags=new ArrayList<>(); private final List<Hashtag> hashtags = new ArrayList<>();
private MenuItem addHashtagItem; private MenuItem addHashtagItem;
private final List<CustomLocalTimeline> localTimelines = new ArrayList<>(); private final List<CustomLocalTimeline> localTimelines = new ArrayList<>();
public EditTimelinesFragment(){ public EditTimelinesFragment() {
super(10); super(10);
ItemTouchHelper.SimpleCallback itemTouchCallback=new ItemTouchHelperCallback(); ItemTouchHelper.SimpleCallback itemTouchCallback = new ItemTouchHelperCallback() ;
itemTouchHelper=new ItemTouchHelper(itemTouchCallback); itemTouchHelper = new ItemTouchHelper(itemTouchCallback);
} }
@Override @Override
public void onCreate(Bundle savedInstanceState){ public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setHasOptionsMenu(true); setHasOptionsMenu(true);
setTitle(R.string.sk_timelines); setTitle(R.string.sk_timelines);
accountID=getArguments().getString("account"); accountID = getArguments().getString("account");
new GetLists().setCallback(new Callback<>(){ new GetLists().setCallback(new Callback<>() {
@Override @Override
public void onSuccess(List<FollowList> result){ public void onSuccess(List<ListTimeline> result) {
followLists.addAll(result); listTimelines.addAll(result);
updateOptionsMenu(); updateOptionsMenu();
} }
@Override @Override
public void onError(ErrorResponse error){ public void onError(ErrorResponse error) {
error.showToast(getContext()); error.showToast(getContext());
} }
}).exec(accountID); }).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>(){ new GetFollowedHashtags().setCallback(new Callback<>() {
@Override @Override
public void onSuccess(HeaderPaginationList<Hashtag> result){ public void onSuccess(HeaderPaginationList<Hashtag> result) {
hashtags.addAll(result); hashtags.addAll(result);
updateOptionsMenu(); updateOptionsMenu();
} }
@Override @Override
public void onError(ErrorResponse error){ public void onError(ErrorResponse error) {
error.showToast(getContext()); error.showToast(getContext());
} }
}).exec(accountID); }).exec(accountID);
} }
@Override @Override
protected void onShown(){ protected void onShown(){
super.onShown(); super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData(); if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData();
} }
@Override @Override
public void onViewCreated(View view, Bundle savedInstanceState){ public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState); super.onViewCreated(view, savedInstanceState);
itemTouchHelper.attachToRecyclerView(list); itemTouchHelper.attachToRecyclerView(list);
refreshLayout.setEnabled(false); refreshLayout.setEnabled(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16)); list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
} }
@Override @Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
this.optionsMenu=menu; this.optionsMenu = menu;
updateOptionsMenu(); updateOptionsMenu();
} }
@Override @Override
public boolean onOptionsItemSelected(MenuItem item){ public boolean onOptionsItemSelected(MenuItem item) {
if(item.getItemId()==R.id.menu_back){ if (item.getItemId() == R.id.menu_back) {
updateOptionsMenu(); updateOptionsMenu();
optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0); optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0);
return true; return true;
} }
if (item.getItemId() == R.id.menu_add_local_timelines) { if (item.getItemId() == R.id.menu_add_local_timelines) {
addNewLocalTimeline(); addNewLocalTimeline();
return true; return true;
} }
@ -158,14 +161,14 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefi
return true; return true;
} }
private void addTimeline(TimelineDefinition tl){ private void addTimeline(TimelineDefinition tl) {
data.add(tl.copy()); data.add(tl.copy());
adapter.notifyItemInserted(data.size()); adapter.notifyItemInserted(data.size());
saveTimelines(); saveTimelines();
updateOptionsMenu(); updateOptionsMenu();
} }
private void addNewLocalTimeline() { private void addNewLocalTimeline() {
FrameLayout inputWrap = new FrameLayout(getContext()); FrameLayout inputWrap = new FrameLayout(getContext());
EditText input = new EditText(getContext()); EditText input = new EditText(getContext());
input.setHint(R.string.sk_example_domain); input.setHint(R.string.sk_example_domain);
@ -191,323 +194,313 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefi
timelineByMenuItem.put(item, tl); timelineByMenuItem.put(item, tl);
} }
private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon){ private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon) {
MenuItem item=menu.add(0, View.generateViewId(), Menu.NONE, name); MenuItem item = menu.add(0, View.generateViewId(), Menu.NONE, name);
item.setIcon(icon); item.setIcon(icon);
return item; return item;
} }
private void updateOptionsMenu(){ private void updateOptionsMenu() {
if(getActivity()==null) return; if (getActivity() == null) return;
optionsMenu.clear(); optionsMenu.clear();
timelineByMenuItem.clear(); timelineByMenuItem.clear();
SubMenu menu=optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add); SubMenu menu = optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add);
menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular); menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular);
SubMenu timelinesMenu=menu.addSubMenu(R.string.sk_timeline); SubMenu timelinesMenu = menu.addSubMenu(R.string.sk_timeline);
timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular); timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular);
SubMenu listsMenu=menu.addSubMenu(R.string.sk_list); SubMenu listsMenu = menu.addSubMenu(R.string.sk_list);
listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular); listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular);
SubMenu hashtagsMenu=menu.addSubMenu(R.string.sk_hashtag); SubMenu hashtagsMenu = menu.addSubMenu(R.string.sk_hashtag);
hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular); hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
MenuItem addLocalTimelines = menu.add(0, R.id.menu_add_local_timelines, NONE, R.string.local_timeline); MenuItem addLocalTimelines = menu.add(0, R.id.menu_add_local_timelines, NONE, R.string.local_timeline);
addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular); addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular);
makeBackItem(timelinesMenu); makeBackItem(timelinesMenu);
makeBackItem(listsMenu); makeBackItem(listsMenu);
makeBackItem(hashtagsMenu); makeBackItem(hashtagsMenu);
TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl->addTimelineToOptions(tl, timelinesMenu)); TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl -> addTimelineToOptions(tl, timelinesMenu));
followLists.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu)); listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu));
addHashtagItem=addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); addHashtagItem = addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular);
hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl->addTimelineToOptions(tl, hashtagsMenu)); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu));
timelinesMenu.getItem().setVisible(timelinesMenu.size()>0); timelinesMenu.getItem().setVisible(timelinesMenu.size() > 0);
listsMenu.getItem().setVisible(listsMenu.size()>0); listsMenu.getItem().setVisible(listsMenu.size() > 0);
hashtagsMenu.getItem().setVisible(hashtagsMenu.size()>0); hashtagsMenu.getItem().setVisible(hashtagsMenu.size() > 0);
UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline); UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline);
} }
private void saveTimelines(){ private void saveTimelines() {
updated=true; updated=true;
AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences();
if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE); if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE);
prefs.timelines=data; prefs.timelines=data;
prefs.save(); prefs.save();
} }
private void removeTimeline(int position){ private void removeTimeline(int position) {
data.remove(position); data.remove(position);
adapter.notifyItemRemoved(position); adapter.notifyItemRemoved(position);
saveTimelines(); saveTimelines();
updateOptionsMenu(); updateOptionsMenu();
} }
@Override @Override
protected void doLoadData(int offset, int count){ protected void doLoadData(int offset, int count){
onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines); onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines);
updateOptionsMenu(); updateOptionsMenu();
} }
@Override @Override
protected RecyclerView.Adapter<TimelineViewHolder> getAdapter(){ protected RecyclerView.Adapter<TimelineViewHolder> getAdapter() {
return adapter=new TimelinesAdapter(); return adapter = new TimelinesAdapter();
} }
@Override @Override
public void scrollToTop(){ public void scrollToTop() {
smoothScrollRecyclerViewToTop(list); smoothScrollRecyclerViewToTop(list);
} }
@Override @Override
public void onDestroy(){ public void onDestroy() {
super.onDestroy(); super.onDestroy();
if(updated) UiUtils.restartApp(); if (updated) UiUtils.restartApp();
} }
private boolean setTagListContent(NachoTextView editText, @Nullable List<String> tags){ private boolean setTagListContent(NachoTextView editText, @Nullable List<String> tags) {
if(tags==null || tags.isEmpty()) return false; if (tags == null || tags.isEmpty()) return false;
editText.setText(tags); editText.setText(String.join(",", tags));
editText.chipifyAllUnterminatedTokens(); editText.chipifyAllUnterminatedTokens();
return true; return true;
} }
private NachoTextView prepareChipTextView(NachoTextView nacho){ private NachoTextView prepareChipTextView(NachoTextView nacho) {
//Ill Be Back nacho.addChipTerminator(',', BEHAVIOR_CHIPIFY_ALL);
nacho.setChipTerminators( nacho.addChipTerminator('\n', BEHAVIOR_CHIPIFY_ALL);
Map.of( nacho.addChipTerminator(' ', BEHAVIOR_CHIPIFY_ALL);
',', BEHAVIOR_CHIPIFY_ALL, nacho.addChipTerminator(';', BEHAVIOR_CHIPIFY_ALL);
'\n', BEHAVIOR_CHIPIFY_ALL, nacho.enableEditChipOnTouch(true, true);
' ', BEHAVIOR_CHIPIFY_ALL, nacho.setOnFocusChangeListener((v, hasFocus) -> nacho.chipifyAllUnterminatedTokens());
';', BEHAVIOR_CHIPIFY_ALL return nacho;
) }
);
nacho.enableEditChipOnTouch(true, true);
nacho.setOnFocusChangeListener((v, hasFocus)->nacho.chipifyAllUnterminatedTokens());
return nacho;
}
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer<TimelineDefinition> onSave, Runnable onRemove){ protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer<TimelineDefinition> onSave, Runnable onRemove) {
Context ctx=getContext(); Context ctx = getContext();
View view=getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false); View view = getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false);
View divider=view.findViewById(R.id.divider); View divider = view.findViewById(R.id.divider);
Button advancedBtn=view.findViewById(R.id.advanced); Button advancedBtn = view.findViewById(R.id.advanced);
EditText editText=view.findViewById(R.id.input); EditText editText = view.findViewById(R.id.input);
if(item!=null) editText.setText(item.getCustomTitle()); if (item != null) editText.setText(item.getCustomTitle());
editText.setHint(item!=null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag)); editText.setHint(item != null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag));
LinearLayout tagWrap=view.findViewById(R.id.tag_wrap); LinearLayout tagWrap = view.findViewById(R.id.tag_wrap);
boolean hashtagOptionsAvailable=item==null || item.getType()==TimelineDefinition.TimelineType.HASHTAG; boolean advancedOptionsAvailable = item == null || item.getType() == TimelineDefinition.TimelineType.HASHTAG;
advancedBtn.setVisibility(hashtagOptionsAvailable ? View.VISIBLE : View.GONE); advancedBtn.setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE);
advancedBtn.setOnClickListener(l->{ advancedBtn.setOnClickListener(l -> {
advancedBtn.setSelected(!advancedBtn.isSelected()); advancedBtn.setSelected(!advancedBtn.isSelected());
advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show);
divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE);
tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE);
UiUtils.beginLayoutTransition((ViewGroup) view); UiUtils.beginLayoutTransition((ViewGroup) view);
}); });
Switch localOnlySwitch=view.findViewById(R.id.local_only_switch); Switch localOnlySwitch = view.findViewById(R.id.local_only_switch);
view.findViewById(R.id.local_only).setOnClickListener(l->localOnlySwitch.setChecked(!localOnlySwitch.isChecked())); view.findViewById(R.id.local_only)
.setOnClickListener(l -> localOnlySwitch.setChecked(!localOnlySwitch.isChecked()));
EditText tagMain=view.findViewById(R.id.tag_main); EditText tagMain = view.findViewById(R.id.tag_main);
NachoTextView tagsAny=prepareChipTextView(view.findViewById(R.id.tags_any)); NachoTextView tagsAny = prepareChipTextView(view.findViewById(R.id.tags_any));
NachoTextView tagsAll=prepareChipTextView(view.findViewById(R.id.tags_all)); NachoTextView tagsAll = prepareChipTextView(view.findViewById(R.id.tags_all));
NachoTextView tagsNone=prepareChipTextView(view.findViewById(R.id.tags_none)); NachoTextView tagsNone = prepareChipTextView(view.findViewById(R.id.tags_none));
if (item != null) {
if(item!=null && hashtagOptionsAvailable){ tagMain.setText(item.getHashtagName());
tagMain.setText(item.getHashtagName()); boolean hasAdvanced = !TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle());
boolean hasAdvanced=!TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle()); hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced;
hasAdvanced=setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced; hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced;
hasAdvanced=setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; hasAdvanced = setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced;
hasAdvanced=setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced; if (item.isHashtagLocalOnly()) {
if(item.isHashtagLocalOnly()){ localOnlySwitch.setChecked(true);
localOnlySwitch.setChecked(true); hasAdvanced = true;
hasAdvanced=true; }
} if (hasAdvanced) {
if(hasAdvanced){ advancedBtn.setSelected(true);
advancedBtn.setSelected(true); advancedBtn.setText(R.string.sk_advanced_options_hide);
advancedBtn.setText(R.string.sk_advanced_options_hide);
tagWrap.setVisibility(View.VISIBLE); tagWrap.setVisibility(View.VISIBLE);
divider.setVisibility(View.VISIBLE); divider.setVisibility(View.VISIBLE);
} }
} }
ImageButton btn=view.findViewById(R.id.button); ImageButton btn = view.findViewById(R.id.button);
PopupMenu popup=new PopupMenu(ctx, btn); PopupMenu popup = new PopupMenu(ctx, btn);
TimelineDefinition.Icon currentIcon=item!=null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG; TimelineDefinition.Icon currentIcon = item != null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG;
btn.setImageResource(currentIcon.iconRes); btn.setImageResource(currentIcon.iconRes);
btn.setTag(currentIcon.ordinal()); btn.setTag(currentIcon.ordinal());
btn.setContentDescription(ctx.getString(currentIcon.nameRes)); btn.setContentDescription(ctx.getString(currentIcon.nameRes));
btn.setOnTouchListener(popup.getDragToOpenListener()); btn.setOnTouchListener(popup.getDragToOpenListener());
btn.setOnClickListener(l->popup.show()); btn.setOnClickListener(l -> popup.show());
Menu menu=popup.getMenu(); Menu menu = popup.getMenu();
TimelineDefinition.Icon defaultIcon=item!=null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG; TimelineDefinition.Icon defaultIcon = item != null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG;
menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes); menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes);
if(!currentIcon.equals(defaultIcon)){ if (!currentIcon.equals(defaultIcon)) {
menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes); menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes);
} }
for(TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()){ for (TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()) {
if(icon.hidden || icon.ordinal()==(int) btn.getTag()) continue; if (icon.hidden || icon.ordinal() == (int) btn.getTag()) continue;
menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes); menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes);
} }
UiUtils.enablePopupMenuIcons(ctx, popup); UiUtils.enablePopupMenuIcons(ctx, popup);
popup.setOnMenuItemClickListener(menuItem->{ popup.setOnMenuItemClickListener(menuItem -> {
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[menuItem.getItemId()]; TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[menuItem.getItemId()];
btn.setImageResource(icon.iconRes); btn.setImageResource(icon.iconRes);
btn.setTag(menuItem.getItemId()); btn.setTag(menuItem.getItemId());
btn.setContentDescription(ctx.getString(icon.nameRes)); btn.setContentDescription(ctx.getString(icon.nameRes));
return true; return true;
}); });
AlertDialog.Builder builder=new M3AlertDialogBuilder(ctx) AlertDialog.Builder builder = new M3AlertDialogBuilder(ctx)
.setTitle(item==null ? R.string.sk_add_timeline : R.string.sk_edit_timeline) .setTitle(item == null ? R.string.sk_add_timeline : R.string.sk_edit_timeline)
.setView(view) .setView(view)
.setPositiveButton(R.string.save, (d, which)->{ .setPositiveButton(R.string.save, (d, which) -> {
String name=editText.getText().toString().trim(); tagsAny.chipifyAllUnterminatedTokens();
tagsAll.chipifyAllUnterminatedTokens();
tagsNone.chipifyAllUnterminatedTokens();
String name = editText.getText().toString().trim();
String mainHashtag = tagMain.getText().toString().trim();
if (TextUtils.isEmpty(mainHashtag)) {
mainHashtag = name;
name = null;
}
if (TextUtils.isEmpty(mainHashtag)) {
Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show();
onSave.accept(null);
return;
}
String mainHashtag=tagMain.getText().toString().trim(); TimelineDefinition tl = item != null ? item : TimelineDefinition.ofHashtag(name);
if(item != null && item.getType()==TimelineDefinition.TimelineType.HASHTAG){ TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[(int) btn.getTag()];
tagsAny.chipifyAllUnterminatedTokens(); tl.setIcon(icon);
tagsAll.chipifyAllUnterminatedTokens(); tl.setTitle(name);
tagsNone.chipifyAllUnterminatedTokens(); tl.setTagOptions(
if(TextUtils.isEmpty(mainHashtag)){ mainHashtag,
mainHashtag=name; tagsAny.getChipValues(),
name=null; tagsAll.getChipValues(),
} tagsNone.getChipValues(),
if(TextUtils.isEmpty(mainHashtag) && (item!=null && item.getType()==TimelineDefinition.TimelineType.HASHTAG)){ localOnlySwitch.isChecked()
Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show(); );
onSave.accept(null); onSave.accept(tl);
return; })
} .setNegativeButton(R.string.cancel, (d, which) -> {});
}
TimelineDefinition tl=item!=null ? item : TimelineDefinition.ofHashtag(name); if (onRemove != null) builder.setNeutralButton(R.string.sk_remove, (d, which) -> onRemove.run());
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[(int) btn.getTag()];
tl.setIcon(icon);
tl.setTitle(name);
if(item == null || item.getType()==TimelineDefinition.TimelineType.HASHTAG){
tl.setTagOptions(
TextUtils.isEmpty(mainHashtag) ? name : mainHashtag,
tagsAny.getChipValues(),
tagsAll.getChipValues(),
tagsNone.getChipValues(),
localOnlySwitch.isChecked()
);
}
onSave.accept(tl);
})
.setNegativeButton(R.string.cancel, (d, which)->{});
if(onRemove!=null) builder.setNeutralButton(R.string.sk_remove, (d, which)->onRemove.run()); builder.show();
btn.requestFocus();
}
builder.show(); private class TimelinesAdapter extends RecyclerView.Adapter<TimelineViewHolder>{
btn.requestFocus(); @NonNull
} @Override
public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new TimelineViewHolder();
}
private class TimelinesAdapter extends RecyclerView.Adapter<TimelineViewHolder>{ @Override
@NonNull public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position) {
@Override holder.bind(data.get(position));
public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ }
return new TimelineViewHolder();
}
@Override @Override
public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position){ public int getItemCount() {
holder.bind(data.get(position)); return data.size();
} }
}
@Override private class TimelineViewHolder extends BindableViewHolder<TimelineDefinition> implements UsableRecyclerView.Clickable{
public int getItemCount(){ private final TextView title;
return data.size(); private final ImageView dragger;
}
}
private class TimelineViewHolder extends BindableViewHolder<TimelineDefinition> implements UsableRecyclerView.Clickable{ public TimelineViewHolder(){
private final TextView title; super(getActivity(), R.layout.item_text, list);
private final ImageView dragger; title=findViewById(R.id.title);
dragger=findViewById(R.id.dragger_thingy);
}
public TimelineViewHolder(){ @SuppressLint("ClickableViewAccessibility")
super(getActivity(), R.layout.item_text, list); @Override
title=findViewById(R.id.title); public void onBind(TimelineDefinition item) {
dragger=findViewById(R.id.dragger_thingy); title.setText(item.getTitle(getContext()));
} title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null);
dragger.setVisibility(View.VISIBLE);
dragger.setOnTouchListener((View v, MotionEvent event) -> {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
itemTouchHelper.startDrag(this);
return true;
}
return false;
});
}
@SuppressLint("ClickableViewAccessibility") private void onSave(TimelineDefinition tl) {
@Override saveTimelines();
public void onBind(TimelineDefinition item){ rebind();
title.setText(item.getTitle(getContext())); }
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null);
dragger.setVisibility(View.VISIBLE);
dragger.setOnTouchListener((View v, MotionEvent event)->{
if(event.getAction()==MotionEvent.ACTION_DOWN){
itemTouchHelper.startDrag(this);
return true;
}
return false;
});
}
private void onSave(TimelineDefinition tl){ private void onRemove() {
saveTimelines(); removeTimeline(getAbsoluteAdapterPosition());
rebind(); }
}
private void onRemove(){ @SuppressLint("ClickableViewAccessibility")
removeTimeline(getAbsoluteAdapterPosition()); @Override
} public void onClick() {
makeTimelineEditor(item, this::onSave, this::onRemove);
}
}
@SuppressLint("ClickableViewAccessibility") private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback {
@Override public ItemTouchHelperCallback() {
public void onClick(){ super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT);
makeTimelineEditor(item, this::onSave, this::onRemove); }
}
}
private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback{ @Override
public ItemTouchHelperCallback(){ public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
super(ItemTouchHelper.UP|ItemTouchHelper.DOWN, ItemTouchHelper.LEFT|ItemTouchHelper.RIGHT); int fromPosition = viewHolder.getAbsoluteAdapterPosition();
} int toPosition = target.getAbsoluteAdapterPosition();
if (Math.max(fromPosition, toPosition) >= data.size() || Math.min(fromPosition, toPosition) < 0) {
return false;
} else {
Collections.swap(data, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
saveTimelines();
return true;
}
}
@Override @Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target){ public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) {
int fromPosition=viewHolder.getAbsoluteAdapterPosition(); if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) {
int toPosition=target.getAbsoluteAdapterPosition(); viewHolder.itemView.animate().alpha(0.65f);
if(Math.max(fromPosition, toPosition)>=data.size() || Math.min(fromPosition, toPosition)<0){ }
return false; }
}else{
Collections.swap(data, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
saveTimelines();
return true;
}
}
@Override @Override
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if(actionState==ItemTouchHelper.ACTION_STATE_DRAG && viewHolder!=null){ super.clearView(recyclerView, viewHolder);
viewHolder.itemView.animate().alpha(0.65f); viewHolder.itemView.animate().alpha(1f);
} }
}
@Override @Override
public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
super.clearView(recyclerView, viewHolder); int position = viewHolder.getAbsoluteAdapterPosition();
viewHolder.itemView.animate().alpha(1f); removeTimeline(position);
} }
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){
int position=viewHolder.getAbsoluteAdapterPosition();
removeTimeline(position);
}
}
} }

View file

@ -27,7 +27,7 @@ public class FavoritedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(HeaderPaginationList<Status> result){ public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return; if (getActivity() == null) return;
if(result.nextPageUri!=null) if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id"); nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else else

View file

@ -46,7 +46,7 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment<Hashtag
@Override @Override
public void onItemClick(String id){ public void onItemClick(String id){
UiUtils.openHashtagTimeline(getActivity(), accountID, Objects.requireNonNull(findItemOfType(id, HashtagStatusDisplayItem.class)).tag); UiUtils.openHashtagTimeline(getActivity(), accountID, id, data.stream().filter(h -> Objects.equals(h.name, id)).findAny().map(h -> h.following).orElse(null));
} }
@Override @Override

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