diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 84d088a9a1..ea2fb6af3e 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,7 @@ # These are supported funding model platforms -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: LucasGGamerM +custom: ["https://liberapay.com/LucasGGamerM/donate", liberapay.com] patreon: # mastodon 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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5aafe8adbc..9f1bb06e97 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -22,8 +22,6 @@ Steps to reproduce the behavior: **Does this happen in the official app?** Does this issue also occur with the respective upstream release? -(Please test using the respective `upstream-xxxxxx.apk` provided in [Releases](https://github.com/sk22/megalodon/releases) or at least using the current Mastodon version from the Play Store) - > No / Yes > In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead. @@ -35,7 +33,7 @@ If applicable, add screenshots (and screen recordings, if possible) to help expl **Version** -Megalodon version: [e.g. v1.1.4+fork.#] +Moshidon version: [e.g. v1.1.4+fork.#] **Crash log** diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml new file mode 100644 index 0000000000..cf02b888d6 --- /dev/null +++ b/.github/workflows/nightly-builds.yml @@ -0,0 +1,71 @@ +name: Nightly builds + +on: + push: + branches: [ "master" ] + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout Appkit Repo + uses: actions/checkout@v3 + with: + repository: grishka/appkit + + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: Comment out signing config in appkits gradle file + run: | + sed -i 's/sign publishing\.publications\.release/\/\/ sign publishing.publications.release/' appkit/maven-push.gradle + + - name: Grant execute permission for gradlew for Appkit + run: chmod +x gradlew + + - name: Compile appkit + run: ./gradlew publishToMavenLocal + + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Decode Keystore + id: decode_keystore + uses: timheuer/base64-to-file@v1 + with: + fileName: 'nightly_keystore.jks' + fileDir: './mastodon/keystore/' + encodedString: ${{ secrets.KEYSTORE }} + + - name: Build with Gradle + run: ./gradlew assembleNightly + env: + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + CURRENT_DATE: ${{ steps.date.outputs.date }} + + - name: Upload a Build Artifact + uses: actions/upload-artifact@v3.1.2 + with: + name: moshidon-nightly.apk + path: ./mastodon/build/outputs/apk/nightly/moshidon-nightly.apk diff --git a/.gitignore b/.gitignore index 593af090de..97a0829e24 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ .cxx local.properties *.jks +*.keystore +/mastodon/keystore/nightly_keystore.keystore diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000000..f18a7ca59d --- /dev/null +++ b/FAQ.md @@ -0,0 +1,9 @@ +## F.A.Q + +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. + +Q: Will there ever be a version of Moshidon for iOS? + +A: No. As android and iOS apps do not share code, it is incredibly hard to port. diff --git a/README.md b/README.md index e3f257b346..98206a28f4 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,123 @@ -![Pink logo with pink shark](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) +![MoshidonLogo](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png) -# Megalodon +# Moshidon, the material you mastodon client! -[![Translation status](https://translate.codeberg.org/widgets/megalodon/-/svg-badge.svg)](https://translate.codeberg.org/engage/megalodon/) -  -[![Download latest release](https://img.shields.io/badge/dynamic/json?color=d92aad&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fsk22%2Fmegalodon%2Freleases%2Flatest&style=flat)](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk) - -Get it on Google Play -  -Get it on IzzyOnDroid +> 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 won’t ever be implemented, such as the federated timeline, unlisted posting, bookmarks and an image description viewer. -> A fork of the [Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app, focusing on [Glitch](https://glitch-soc.github.io/docs) compatibility, a pretty UI and adding new features that I feel make using the Fediverse a more pleasant experience. +[![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) -## Key features +[![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) -### **Unlisted posting** -
-

Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Community”, “Federated” and “Posts”).

+[![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) -When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s Home timelines, but only if they follow you or someone they follow reblogged/replied to your post. +Get it on Google Play +  +Get it on IzzyOnDroid -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). -
+## Help out the project by donating at: https://github.com/sponsors/LucasGGamerM! +### We also support LiberaPay at: https://liberapay.com/LucasGGamerM/donate (Currently broken) -### **Federated timeline** +### You can also donate some Monero through this wallet address as well: +4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j -
-

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 wasn’t included in the official Mastodon app – supposedly, because this conflicts with Google’s safety requirements for apps on the Play Store. - -That’s 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! -
+## Key features -### **Customizable timelines** +### **The ability to add other server's local timeline to your timelines** -
-

You can customize Megalodon’s home tab and not only add local and federated timelines, but also pin lists and hashtags.

+It can be accessed in the "Edit timelines" menu, where you can add a new "Community" to see other server's local posts! -Even better: You can rename every timeline however you please and pick a distinct icon for each timeline. This way, you can pin the hashtag “#Caturday”, rename your timeline to “CUTENESS OVERLOAD” and set Cat icon from Microsoft Fluent UI icons as its icon. :3 You can find the timelines editor by opening your home tab, tapping the `⋮` button in the top right and going to “Edit timelines”. -
+### **View remote profiles** -### **Draft and schedule posts** +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. -
-

-Allows to prepare a post and schedule it to send it automatically at a specific time.

+### **Translate posts easily** -You can create drafts, edit them, send them manually later or set a scheduled date. Drafts are technically saved as scheduled posts, so you can view and edit them from other apps that support scheduled posts. Scheduled posts are handled by your home instance, so they'll work even if you uninstall Megalodon. -
+Allows you to easily translate posts in another language with a translate button! Your instance must support translation, otherwise it will not work. -## Installation +### **Show posts filtered with a warning** -### Google Play Store +Allows you to have filtered posts collapsed with a warning! As shown in the screenshots: -[https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk](https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk) +Before | After +:-------------------------:|:-------------------------: +![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) -Get it on Google Play -### F-Droid via IzzyOnDroid +### **Color themes** -[https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk) +Allows you to change theme within the app. Supports Material You, purple, pink, green, blue, red, orange, yellow and Nord! -Get it on IzzyOnDroid +### **Unlisted posting** -Note that you'll need to add Izzy's F-Droid repository to your F-Droid app first: +**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”).** -[`https://apt.izzysoft.de/fdroid/repo`](https://apt.izzysoft.de/fdroid/repo) +When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s 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). -### F-Droid via saunarepo +### **Federated timeline** -[https://repo.the-sauna.icu](https://repo.the-sauna.icu/) +**This allows you to chronologically see all Public posts from people on all other Fediverse neighborhoods your home instance is connected to.** -Get it on SaunaRepo +Despite being one of the main features of federated social media, the Federated timeline wasn’t included in the official Mastodon app – supposedly, because this conflicts with Google’s safety requirements for apps on the Play Store. + +That’s 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! -### F-Droid +### **Image description viewer** -**[F-Droid.org?](https://f-droid.org)** Not yet, sorry! +**Allows you to quickly check whether an image or video has an alternative text attached to it.** -If you want, you can help me figure out if something's missing in the [Issue #47: F-Droid.org](https://github.com/sk22/megalodon/issues/47) +This is important to **ensure the content you’re sharing is as accessible as possible** to people who can’t see the images and rely on software to read back the provided content descriptions. Thankfully, it’s quite common for people on the Fediverse to provide such alt texts, and hopefully things stay this way! -### Direct +### **Reminder to add alt text to attached media** -Press the download button to download the APK. Open the downloaded file on your Android device to install it. Megalodon will automatically notify you about new updates inside the app. +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. -[![Download latest release](https://img.shields.io/badge/dynamic/json?color=d92aad&label=Download%20APK&query=%24.tag_name&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fsk22%2Fmegalodon%2Freleases%2Flatest&style=flat)](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk) +### **Pinning posts** -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/sk22/megalodon/releases) page. +**This lets you can highlight important posts on your profile. A dedicated “Pinned” tab in people’s profiles shows all the posts they pinned.** -Megalodon makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)’s automatic update checker. Megalodon 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! +On the Fediverse, it’s 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.** -## Release variants +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 won’t know you saved their post – the list of bookmarked posts is only visible to you. -All downloads can be found on the [Releases](https://github.com/sk22/megalodon/releases) page. When downloading a pre-release, expect to see unfinished features and bugs. If you don’t want that, just download the [latest full release](https://github.com/sk22/megalodon/releases/latest/download/megalodon.apk). +## Installation -**`megalodon.apk`** +**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.** -Variant with an integrated updater. If you download Megalodon from here (and not from an app store), just download the regular `megalodon.apk`. +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. -**`upstream-1234abc.apk`** +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! -This is an **unmodified version** of the official [Mastodon for Android](https://github.com/mastodon/mastodon-android) app the respective Megalodon release is based on. Should you find any bugs in Megalodon (which you will), try to see if it occurs with this variant, too. The last 7 digits of the file name are important to know which version of the official app you're using. +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. - +### Stable variant ---- +All stable version downloads can be found on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page. -## Contribution +**`moshidon.apk`** -### Translation +Variant with an integrated updater. If you download Moshidon from here (and not from an app store), just download the regular `moshidon.apk`. -The translation for the base of the app is sourced from the upstream **Mastodon for Android** project, which you can contribute to on its Crowdin project: [https://crowdin.com/project/mastodon-for-android](https://crowdin.com/project/mastodon-for-android) +### Nightly variant -There's also a bunch of custom strings exclusive to this project that need to be translated. You can help translate **Megalodon** on Weblate: [https://translate.codeberg.org/projects/megalodon](https://translate.codeberg.org/projects/megalodon) +All nightly builds can be downloaded at [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page. -[![Translation status](https://translate.codeberg.org/widgets/megalodon/-/horizontal-auto.svg)](https://translate.codeberg.org/engage/megalodon) +**`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. --- @@ -131,16 +126,25 @@ There's also a bunch of custom strings exclusive to this project that need to be ### 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)) -* [Add “Federation” tab and change Discover tab order](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/add-federated-timeline) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/8)) +* 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 reblogs 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)) +* [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) @@ -150,27 +154,13 @@ There's also a bunch of custom strings exclusive to this project that need to be * [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) -* [Add push notification setting for post notifications](https://github.com/sk22/megalodon/commit/b190480d7739be47f23543d9e7644660f9b4b4ee) -* [Add option to allow voting for multiple options on polls](https://github.com/sk22/megalodon/commit/5b28468efd49387b4f8b83f142f3adf3104ca60c) -* [Add translate function](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/translate-button) -* [Add language selector](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/language-selector) -* [Implement deleting notifications](https://github.com/sk22/megalodon/commit/b0f9ce081f69f29ad59658fc00ca41372cd2677d) (disabled by default) -* [Long-click boost button to "quote" a post](https://github.com/sk22/megalodon/commit/b25a237c20c6a924ed4d9b357999867c3a32b32b) -* [Draft and schedule posts](https://github.com/sk22/megalodon/pull/217) -* [Display original post when replying](https://github.com/sk22/megalodon/commit/375f8ceb2747705fedf43686681cc0e0b812f899) -* [Display server announcements](https://github.com/sk22/megalodon/commit/84179bc207d6b69cc2a770a3c28fa0a39b0b54e8) -* [Create](https://github.com/sk22/megalodon/commit/294595513a45037359b31377aafc25ae5b58d8e7), [edit](https://github.com/sk22/megalodon/commit/d47797bf7ac8cff3f9ba1cfee219a1bb2af21da6) and [delete](https://github.com/sk22/megalodon/commit/54c29fd787fc2cd0dfd2787ad796b8190f795973) lists -* [Soft-blocking (by blocking and immediately unblocking)](https://github.com/sk22/megalodon/commit/e75d350b7a2709259e9fc5138e0e1f361bdb0972) -* [Pinnable custom timelines](https://github.com/sk22/megalodon/pull/338/commits) -* Support for local-only posts -* Support for copying the URL to posts/accounts/… in Pixel launcher’s Recent apps view -* Compatibility for Akkoma Bubble timeline -* Listings of followers/following/favorites/boosts can be loaded from the origin instance (there’s an option to disable this in in the settings) -* Allow opening posts/accounts in-app by sharing a URL/handle to Megalodon (Originally implemented in [Moshidon](https://github.com/LucasGGamerM/moshidon), [PR](https://github.com/sk22/megalodon/pull/531)) +* [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)) @@ -178,22 +168,6 @@ There's also a bunch of custom strings exclusive to this project that need to be * [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) -* [No ellipsis for long poll answers](https://github.com/mastodon/mastodon-android/commit/c9aae828e2518adccdc092e41f8d1f0489636271) -* [Show poll vote button for multiple and single answer polls](https://github.com/mastodon/mastodon-android/commit/e14dfda2fdf32f0fa3043504ac5831683a87559a) -* [Show own vote after voting](https://github.com/mastodon/mastodon-android/commit/4ab9e25fec4fd9c10b7a8ddd1be522b3cc12cf28) ([Closes issue](https://github.com/mastodon/mastodon-android/commit/4ab9e25fec4fd9c10b7a8ddd1be522b3cc12cf28)) -* [Make inline emoji search case-insensitive and don't only search from start of emoji names](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:better-inline-emoji-search) ([Pull request](https://github.com/mastodon/mastodon-android/pull/445)) -* [Include subject line when sharing e.g. a website to Megalodon](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:external-share-include-subject) -* [Improve semantics for voting on polls (radio buttons and checkboxes)](https://github.com/sk22/megalodon/commit/6fd58c96827cb1d2da329cebdc170a1425dd18d7) -* [Copy post URL when long-pressing share button](https://github.com/sk22/megalodon/commit/ba36347f03278763ecec617b1ce57ba89db7be72) -* [Add option to disable swiping between tabs](https://github.com/sk22/megalodon/commit/1f20b21fc84bf006c1ec14bd2229cbfad5215ec8) -* [Resolve Fediverse links in the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/open-urls-in-app) -* [Preserve whitespaces in HTML](https://github.com/sk22/megalodon/commit/7d876bddc7a07d98f0fecbf62b13bdb9fcce3412) -* [Long-click to copy links](https://github.com/sk22/megalodon/commit/b32e32274923a94742a9926ef38785f746d41405) -* Improved filtering using Mastodon 4.0 API: [#202](https://github.com/sk22/megalodon/pull/202), [#212](https://github.com/sk22/megalodon/pull/212), [#255](https://github.com/sk22/megalodon/pull/255) by [@thiagojedi](https://github.com/thiagojedi) -* [Support admin notifications](https://github.com/sk22/megalodon/commit/c12a6eaee6b609bc53eb0a45d9199f37d5241801) and [notifications for edited reblogged posts](https://github.com/sk22/megalodon/commit/900e8fb2e9353002c16d15e06b78d2731e121601) -* [Android file opener added back in addition to image picker](https://github.com/sk22/megalodon/commit/3a6ace53d5ab01e28077c9c930cb6ed487b78031) -* [Replies are inserted below the replied-to post in thread view](https://github.com/sk22/megalodon/commit/87c37df370ec24aeea0d2dbaeb29468aa4fb5808) -* Option to auto-reveal equal content warnings in threads ### Visual @@ -201,17 +175,6 @@ There's also a bunch of custom strings exclusive to this project that need to be * [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) -* [Custom color themes](https://github.com/sk22/megalodon/pull/124) by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Custom "megalodon" text logo](https://github.com/sk22/megalodon/commit/563afd487ca5c608cfbb00fa3909d3c27384acc0) by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Custom login screen](https://github.com/sk22/megalodon/commit/9bbf8c4618dbe13accaeb3b5482bf3fe88cac4c0) -* [More distinct filled boost icon](https://github.com/sk22/megalodon/commits/more-distinct-filled-boost-icon) -* Material You color theme by [@LucasGGamerM](https://github.com/LucasGGamerM) -* [Animations for interaction buttons](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/animate-buttons) -* [Dedicated icons for different notification types](https://github.com/sk22/megalodon/pull/178) by [@florian-obernberger](https://github.com/florian-obernberger) -* Scale text according to system settings -* Header in timeline for followed hashtags -* [Indicator for missing alt texts](https://github.com/sk22/megalodon/commit/c0c276f03e793b78c478c17dfdef24a66ef7cedb) -* Visually grouped (by removing divider lines and reducing padding) threaded replies in thread view ## Building @@ -222,12 +185,18 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth ./gradlew assembleRelease ``` -Note that Megalodon might be depending on an in-development version of [AppKit](https://github.com/grishka/appkit) – a library by Mastodon for Android’s developer. In case the used AppKit version isn’t published to Maven Central yet, you might have to clone, build and publish it to your local Maven repository. For more information, see [this GitHub issue](https://github.com/mastodon/mastodon-android/issues/375#issuecomment-1507678585). - ## License This project is released under the [GPL-3 License](./LICENSE). ## Links -@megalodon@floss.social +[F.A.Q](FAQ.md) + +[Official matrix chatroom:](https://matrix.to/#/#moshidon:floss.social) https://matrix.to/#/#moshidon:floss.social + +[Moshidon roadmap](https://github.com/users/LucasGGamerM/projects/1) + +@moshidon@floss.social + +--- diff --git a/_config.yml b/_config.yml index 4e7d76b759..402335c591 100644 --- a/_config.yml +++ b/_config.yml @@ -1,2 +1,2 @@ -title: Megalodon +title: Moshidon layout: default diff --git a/_layouts/default.html b/_layouts/default.html index 490f899035..1007430d67 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -4,7 +4,7 @@ - Megalodon + Moshidon @@ -14,4 +14,4 @@ {{ content }} - \ No newline at end of file + diff --git a/build.gradle b/build.gradle index 5f07f79051..1bf260e0d7 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { includeModule 'com.github.UnifiedPush', 'android-connector' } } + mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:8.0.0' diff --git a/fix-metadata-markdown-lists.sh b/fix-metadata-markdown-lists.sh old mode 100755 new mode 100644 diff --git a/gradle.properties b/gradle.properties index 415e17f9fe..cd211a89fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,4 +19,5 @@ android.useAndroidX=true android.enableJetifier=false android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/mastodon/build.gradle b/mastodon/build.gradle index a48312df95..514c47306a 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -11,16 +11,41 @@ java { android { compileSdk 33 defaultConfig { - archivesBaseName = "megalodon" - applicationId "org.joinmastodon.android.sk" + manifestPlaceholders = [oAuthScheme:"moshidon-android-auth"] + archivesBaseName = "moshidon" + applicationId "org.joinmastodon.android.moshinda" minSdk 23 targetSdk 33 - versionCode 108 - versionName "2.1.6+fork.108" + versionCode 102 + versionName "2.1.4+fork.102.moshinda" 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'] } + signingConfigs { + nightly{ + storeFile = file("keystore/nightly_keystore.jks") + storePassword System.getenv("SIGNING_STORE_PASSWORD") + if (storePassword == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + storePassword = properties.getProperty('SIGNING_STORE_PASSWORD') + } + keyAlias System.getenv("SIGNING_KEY_ALIAS") + if (keyAlias == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + keyAlias = properties.getProperty('SIGNING_KEY_ALIAS') + } + keyPassword System.getenv("SIGNING_KEY_PASSWORD") + if (keyPassword == null) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + keyPassword = properties.getProperty('SIGNING_KEY_PASSWORD') + } + } + } + buildTypes { release { minifyEnabled true @@ -31,6 +56,29 @@ android { debuggable true versionNameSuffix '-debug' applicationIdSuffix '.debug' + manifestPlaceholders = [oAuthScheme:"moshidon-android-debug-auth"] + } + githubRelease{ + initWith release + } + nightly{ + if(System.getenv("CURRENT_DATE") != null){ + versionNameSuffix '-nightly+@' + System.getenv("CURRENT_DATE") + } else { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + versionNameSuffix '-nightly+@' + properties.getProperty('CURRENT_DATE') + } + applicationIdSuffix '.nightly' + + signingConfig signingConfigs.nightly + manifestPlaceholders = [oAuthScheme:"moshidon-android-nightly-auth"] + } + playRelease{ + initWith release + minifyEnabled true + shrinkResources true + versionNameSuffix '-play' } githubRelease { initWith release } playRelease { initWith release } @@ -46,7 +94,7 @@ android { setRoot "src/github" } debug { - setRoot "src/github" + setRoot "src/debug" } } namespace 'org.joinmastodon.android' diff --git a/mastodon/proguard-rules.pro b/mastodon/proguard-rules.pro index f12799a6f5..39538ac740 100644 --- a/mastodon/proguard-rules.pro +++ b/mastodon/proguard-rules.pro @@ -45,6 +45,13 @@ -keepattributes LineNumberTable +-keepattributes * +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +#-keep class javax.** { *; } +-keep class org.joinmastodon.android.** { *; } + # Parceler library -keep interface org.parceler.Parcel -keep @org.parceler.Parcel class * { *; } diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java index 84f156706e..8ad1ff6890 100644 --- a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java @@ -19,7 +19,9 @@ public class StatusFilterPredicateTest { private static final Status hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()), - warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now()); + warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now()), + noAltText = Status.ofFake(null, "display me with a warning", Instant.now()), + withAltText = Status.ofFake(null, "display me with a warning", Instant.now()); static { hideMeFilter.phrase = "hide me"; @@ -29,6 +31,12 @@ public class StatusFilterPredicateTest { warnMeFilter.phrase = "warning"; warnMeFilter.filterAction = WARN; warnMeFilter.context = EnumSet.of(PUBLIC, HOME); + + noAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); + withAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable()); + for (Attachment mediaAttachment : withAltText.mediaAttachments) { + mediaAttachment.description = "Alt Text"; + } } @Test @@ -78,4 +86,16 @@ public void testWarnWithHideText() { assertTrue("should pass because matching filter is for hiding", new StatusFilterPredicate(allFilters, HOME, WARN).test(hideInHomePublic)); } + + @Test + public void testAltTextFilterNoPass() { + assertFalse("should not pass because of no alt text", + new StatusFilterPredicate(allFilters, HOME).test(noAltText)); + } + + @Test + public void testAltTextFilterPass() { + assertTrue("should pass because of alt text", + new StatusFilterPredicate(allFilters, HOME).test(withAltText)); + } } \ No newline at end of file diff --git a/mastodon/src/github/AndroidManifest.xml b/mastodon/src/debug/AndroidManifest.xml similarity index 81% rename from mastodon/src/github/AndroidManifest.xml rename to mastodon/src/debug/AndroidManifest.xml index 5838d1c434..aba7ef431c 100644 --- a/mastodon/src/github/AndroidManifest.xml +++ b/mastodon/src/debug/AndroidManifest.xml @@ -1,9 +1,14 @@ - + - + + diff --git a/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java new file mode 100644 index 0000000000..e4c5b321bf --- /dev/null +++ b/mastodon/src/debug/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -0,0 +1,378 @@ +package org.joinmastodon.android.updater; + +import android.app.Activity; +import android.app.DownloadManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageInstaller; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.widget.Toast; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; + +import java.io.File; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import androidx.annotation.Keep; +import okhttp3.Call; +import okhttp3.Request; +import okhttp3.Response; + +@Keep +public class GithubSelfUpdaterImpl extends GithubSelfUpdater{ + private static final long CHECK_PERIOD=6*3600*1000L; + private static final String TAG="GithubSelfUpdater"; + + private UpdateState state=UpdateState.NO_UPDATE; + private UpdateInfo info; + private long downloadID; + private BroadcastReceiver downloadCompletionReceiver=new BroadcastReceiver(){ + @Override + public void onReceive(Context context, Intent intent){ + if(downloadID!=0 && intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)==downloadID){ + MastodonApp.context.unregisterReceiver(this); + setState(UpdateState.DOWNLOADED); + } + } + }; + + public GithubSelfUpdaterImpl(){ + SharedPreferences prefs=getPrefs(); + int checkedByBuild=prefs.getInt("checkedByBuild", 0); + if(prefs.contains("version") && checkedByBuild==BuildConfig.VERSION_CODE){ + info=new UpdateInfo(); + info.version=prefs.getString("version", null); + info.size=prefs.getLong("apkSize", 0); + info.changelog=prefs.getString("changelog", null); + downloadID=prefs.getLong("downloadID", 0); + if(downloadID==0 || !getUpdateApkFile().exists()){ + state=UpdateState.UPDATE_AVAILABLE; + }else{ + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + state=dm.getUriForDownloadedFile(downloadID)==null ? UpdateState.DOWNLOADING : UpdateState.DOWNLOADED; + if(state==UpdateState.DOWNLOADING){ + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + } + } + }else if(checkedByBuild!=BuildConfig.VERSION_CODE && checkedByBuild>0){ + // We are in a new version, running for the first time after update. Gotta clean things up. + long id=getPrefs().getLong("downloadID", 0); + if(id!=0){ + MastodonApp.context.getSystemService(DownloadManager.class).remove(id); + } + getUpdateApkFile().delete(); + getPrefs().edit() + .remove("apkSize") + .remove("version") + .remove("apkURL") + .remove("checkedByBuild") + .remove("downloadID") + .remove("changelog") + .apply(); + } + } + + private SharedPreferences getPrefs(){ + return MastodonApp.context.getSharedPreferences("githubUpdater", Context.MODE_PRIVATE); + } + + @Override + public void maybeCheckForUpdates(){ + if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE) + return; + long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", CHECK_PERIOD); + if(timeSinceLastCheck>=CHECK_PERIOD){ + setState(UpdateState.CHECKING); + MastodonAPIController.runInBackground(this::actuallyCheckForUpdates); + } + } + + @Override + public void checkForUpdates() { + setState(UpdateState.CHECKING); + MastodonAPIController.runInBackground(this::actuallyCheckForUpdates); + } + + private void actuallyCheckForUpdates(){ + Request req=new Request.Builder() + .url("https://api.github.com/repos/LucasGGamerM/moshidon/releases") + .build(); + Call call=MastodonAPIController.getHttpClient().newCall(req); + try(Response resp=call.execute()){ + JsonArray arr=JsonParser.parseReader(resp.body().charStream()).getAsJsonArray(); + for (JsonElement jsonElement : arr) { + JsonObject obj = jsonElement.getAsJsonObject(); + if (obj.get("prerelease").getAsBoolean() && !GlobalUserPreferences.enablePreReleases) continue; + + String tag=obj.get("tag_name").getAsString(); + String changelog=obj.get("body").getAsString(); + Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)\\+fork\\.(\\d+)"); + Matcher matcher=pattern.matcher(tag); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag); + return; + } + int newMajor=Integer.parseInt(matcher.group(1)), + newMinor=Integer.parseInt(matcher.group(2)), + newRevision=Integer.parseInt(matcher.group(3)), + newForkNumber=Integer.parseInt(matcher.group(4)); + matcher=pattern.matcher(BuildConfig.VERSION_NAME); + String[] currentParts=BuildConfig.VERSION_NAME.split("[.+]"); + if(!matcher.find()){ + Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME); + return; + } + int curMajor=Integer.parseInt(matcher.group(1)), + curMinor=Integer.parseInt(matcher.group(2)), + curRevision=Integer.parseInt(matcher.group(3)), + curForkNumber=Integer.parseInt(matcher.group(4)); + long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision; + long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision; + if(newVersion>curVersion || newForkNumber>curForkNumber){ + String version=newMajor+"."+newMinor+"."+newRevision+"+fork."+newForkNumber; + Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); + for(JsonElement el:obj.getAsJsonArray("assets")){ + JsonObject asset=el.getAsJsonObject(); + if("moshidon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ + long size=asset.get("size").getAsLong(); + String url=asset.get("browser_download_url").getAsString(); + + UpdateInfo info=new UpdateInfo(); + info.size=size; + info.version=version; + info.changelog=changelog; + this.info=info; + + getPrefs().edit() + .putLong("apkSize", size) + .putString("version", version) + .putString("apkURL", url) + .putString("changelog", changelog) + .putInt("checkedByBuild", BuildConfig.VERSION_CODE) + .remove("downloadID") + .apply(); + + break; + } + } + } + getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply(); + break; + } + }catch(Exception x){ + Log.w(TAG, "actuallyCheckForUpdates", x); + }finally{ + setState(info==null ? UpdateState.NO_UPDATE : UpdateState.UPDATE_AVAILABLE); + } + } + + private void setState(UpdateState state){ + this.state=state; + E.post(new SelfUpdateStateChangedEvent(state)); + } + + @Override + public UpdateState getState(){ + return state; + } + + @Override + public UpdateInfo getUpdateInfo(){ + return info; + } + + public File getUpdateApkFile(){ + return new File(MastodonApp.context.getExternalCacheDir(), "update.apk"); + } + + @Override + public void downloadUpdate(){ + if(state==UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); + downloadID=dm.enqueue( + new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null))) + .setDestinationUri(Uri.fromFile(getUpdateApkFile())) + ); + getPrefs().edit().putLong("downloadID", downloadID).apply(); + setState(UpdateState.DOWNLOADING); + } + + @Override + public void installUpdate(Activity activity){ + if(state!=UpdateState.DOWNLOADED) + throw new IllegalStateException(); + Uri uri; + Intent intent=new Intent(Intent.ACTION_INSTALL_PACKAGE); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + uri=new Uri.Builder().scheme("content").authority(activity.getPackageName()+".self_update_provider").path("update.apk").build(); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + }else{ + uri=Uri.fromFile(getUpdateApkFile()); + } + intent.setDataAndType(uri, "application/vnd.android.package-archive"); + activity.startActivity(intent); + + // TODO figure out how to restart the app when updating via this new API + /* + PackageInstaller installer=activity.getPackageManager().getPackageInstaller(); + try{ + final int sid=installer.createSession(new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)); + installer.registerSessionCallback(new PackageInstaller.SessionCallback(){ + @Override + public void onCreated(int i){ + + } + + @Override + public void onBadgingChanged(int i){ + + } + + @Override + public void onActiveChanged(int i, boolean b){ + + } + + @Override + public void onProgressChanged(int id, float progress){ + + } + + @Override + public void onFinished(int id, boolean success){ + activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + }); + activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + PackageInstaller.Session session=installer.openSession(sid); + try(OutputStream out=session.openWrite("mastodon.apk", 0, info.size); InputStream in=new FileInputStream(getUpdateApkFile())){ + byte[] buffer=new byte[16384]; + int read; + while((read=in.read(buffer))>0){ + out.write(buffer, 0, read); + } + } +// PendingIntent intent=PendingIntent.getBroadcast(activity, 1, new Intent(activity, InstallerStatusReceiver.class), PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE); + PendingIntent intent=PendingIntent.getActivity(activity, 1, new Intent(activity, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + session.commit(intent.getIntentSender()); + }catch(IOException x){ + Log.w(TAG, "installUpdate", x); + Toast.makeText(activity, x.getMessage(), Toast.LENGTH_SHORT).show(); + } + */ + } + + @Override + public float getDownloadProgress(){ + if(state!=UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + try(Cursor cursor=dm.query(new DownloadManager.Query().setFilterById(downloadID))){ + if(cursor.moveToFirst()){ + long loaded=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); + long total=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); +// Log.d(TAG, "getDownloadProgress: "+loaded+" of "+total); + return total>0 ? (float)loaded/total : 0f; + } + } + return 0; + } + + @Override + public void cancelDownload(){ + if(state!=UpdateState.DOWNLOADING) + throw new IllegalStateException(); + DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class); + dm.remove(downloadID); + downloadID=0; + getPrefs().edit().remove("downloadID").apply(); + setState(UpdateState.UPDATE_AVAILABLE); + } + + @Override + public void handleIntentFromInstaller(Intent intent, Activity activity){ + int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0); + if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){ + Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT); + activity.startActivity(confirmIntent); + }else if(status!=PackageInstaller.STATUS_SUCCESS){ + String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + Toast.makeText(activity, activity.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void reset(){ + getPrefs().edit().clear().apply(); + File apk=getUpdateApkFile(); + if(apk.exists()) + apk.delete(); + state=UpdateState.NO_UPDATE; + } + + /*public static class InstallerStatusReceiver extends BroadcastReceiver{ + + @Override + public void onReceive(Context context, Intent intent){ + int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0); + if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){ + Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT); + context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + }else if(status!=PackageInstaller.STATUS_SUCCESS){ + String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + Toast.makeText(context, context.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show(); + } + } + } + + public static class AfterUpdateRestartReceiver extends BroadcastReceiver{ + + @Override + public void onReceive(Context context, Intent intent){ + if(Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())){ + context.getPackageManager().setComponentEnabledSetting(new ComponentName(context, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + Toast.makeText(context, R.string.update_installed, Toast.LENGTH_SHORT).show(); + Intent restartIntent=new Intent(context, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setPackage(context.getPackageName()); + if(Build.VERSION.SDK_INT + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml new file mode 100644 index 0000000000..e32e4fab5b --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml new file mode 100644 index 0000000000..2ea945f3de --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mail_inbox_dismiss_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml new file mode 100644 index 0000000000..77a55c4959 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_mention_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml new file mode 100644 index 0000000000..e18fe0aedb --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_open_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml new file mode 100644 index 0000000000..d20ea1330f --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_sign_out_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml new file mode 100644 index 0000000000..8a35ff41e8 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml new file mode 100644 index 0000000000..53c6f5b6a5 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_0_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml new file mode 100644 index 0000000000..e1b6ba1c9e --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml new file mode 100644 index 0000000000..05defaa38b --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_fluent_speaker_off_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml b/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml new file mode 100644 index 0000000000..3faa6b9974 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_launcher_foreground_debug.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml b/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml new file mode 100644 index 0000000000..3faa6b9974 --- /dev/null +++ b/mastodon/src/debug/res/drawable/ic_launcher_foreground_monochrome_debug.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..16a3a26778 --- /dev/null +++ b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..16a3a26778 --- /dev/null +++ b/mastodon/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..e867bc8770 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..4e20506d70 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..4bb62962b2 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..065ca7d71b Binary files /dev/null and b/mastodon/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..df1a45557d Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..d336b3dc95 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..165514c030 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..8d8aa6fa92 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..351a649b13 Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..e0709f895c Binary files /dev/null and b/mastodon/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mastodon/src/debug/res/values/ic_launcher_background.xml b/mastodon/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..beab31f753 --- /dev/null +++ b/mastodon/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java index caced56a9a..73ca393d84 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -115,7 +115,7 @@ public void checkForUpdates() { private void actuallyCheckForUpdates(){ Request req=new Request.Builder() - .url("https://api.github.com/repos/sk22/megalodon/releases") + .url("https://api.github.com/repos/LucasGGamerM/moshidon/releases") .build(); Call call=MastodonAPIController.getHttpClient().newCall(req); try(Response resp=call.execute()){ @@ -154,7 +154,7 @@ private void actuallyCheckForUpdates(){ Log.d(TAG, "actuallyCheckForUpdates: new version: "+version); for(JsonElement el:obj.getAsJsonArray("assets")){ JsonObject asset=el.getAsJsonObject(); - if("megalodon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ + if("moshidon.apk".equals(asset.get("name").getAsString()) && "application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){ long size=asset.get("size").getAsLong(); String url=asset.get("browser_download_url").getAsString(); diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 3444c78189..6e37876a08 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,16 +1,21 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + package="org.joinmastodon.android"> + + @@ -25,11 +30,12 @@ @@ -58,7 +64,7 @@ - + + + + + \ No newline at end of file diff --git a/mastodon/src/main/ic_launcher-playstore.png b/mastodon/src/main/ic_launcher-playstore.png index ffc0e5991b..a3d056a936 100644 Binary files a/mastodon/src/main/ic_launcher-playstore.png and b/mastodon/src/main/ic_launcher-playstore.png differ diff --git a/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE b/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/mastodon/src/main/java/name/fraser/neil/plaintext/LICENSE @@ -0,0 +1,202 @@ + + 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. diff --git a/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java b/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java new file mode 100644 index 0000000000..9d07867de5 --- /dev/null +++ b/mastodon/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java @@ -0,0 +1,2471 @@ +/* + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * 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 name.fraser.neil.plaintext; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/* + * Functions for diff, match and patch. + * Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * Also contains the behaviour settings. + */ +public class diff_match_patch { + + // Defaults. + // Set these on your diff_match_patch instance to override the defaults. + + /** + * Number of seconds to map a diff before giving up (0 for infinity). + */ + public float Diff_Timeout = 1.0f; + /** + * Cost of an empty edit operation in terms of edit characters. + */ + public short Diff_EditCost = 4; + /** + * At what point is no match declared (0.0 = perfection, 1.0 = very loose). + */ + public float Match_Threshold = 0.5f; + /** + * How far to search for a match (0 = exact location, 1000+ = broad match). + * A match this many characters away from the expected location will add + * 1.0 to the score (0.0 is a perfect match). + */ + public int Match_Distance = 1000; + /** + * When deleting a large block of text (over ~64 characters), how close do + * the contents have to be to match the expected contents. (0.0 = perfection, + * 1.0 = very loose). Note that Match_Threshold controls how closely the + * end points of a delete need to match. + */ + public float Patch_DeleteThreshold = 0.5f; + /** + * Chunk size for context length. + */ + public short Patch_Margin = 4; + + /** + * The number of bits in an int. + */ + private short Match_MaxBits = 32; + + /** + * Internal class for returning results from diff_linesToChars(). + * Other less paranoid languages just use a three-element array. + */ + protected static class LinesToCharsResult { + protected String chars1; + protected String chars2; + protected List lineArray; + + protected LinesToCharsResult(String chars1, String chars2, + List lineArray) { + this.chars1 = chars1; + this.chars2 = chars2; + this.lineArray = lineArray; + } + } + + + // DIFF FUNCTIONS + + + /** + * The data structure representing a diff is a Linked list of Diff objects: + * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"), + * Diff(Operation.EQUAL, " world.")} + * which means: delete "Hello", add "Goodbye" and keep " world." + */ + public enum Operation { + DELETE, INSERT, EQUAL + } + + /** + * Find the differences between two texts. + * Run a faster, slightly less optimal diff. + * This method allows the 'checklines' of diff_main() to be optional. + * Most of the time checklines is wanted, so default to true. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2) { + return diff_main(text1, text2, true); + } + + /** + * Find the differences between two texts. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2, + boolean checklines) { + // Set a deadline by which time the diff must be complete. + long deadline; + if (Diff_Timeout <= 0) { + deadline = Long.MAX_VALUE; + } else { + deadline = System.currentTimeMillis() + (long) (Diff_Timeout * 1000); + } + return diff_main(text1, text2, checklines, deadline); + } + + /** + * Find the differences between two texts. Simplifies the problem by + * stripping any common prefix or suffix off the texts before diffing. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. Used + * internally for recursive calls. Users should set DiffTimeout instead. + * @return Linked List of Diff objects. + */ + private LinkedList diff_main(String text1, String text2, + boolean checklines, long deadline) { + // Check for null inputs. + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (diff_main)"); + } + + // Check for equality (speedup). + LinkedList diffs; + if (text1.equals(text2)) { + diffs = new LinkedList(); + if (text1.length() != 0) { + diffs.add(new Diff(Operation.EQUAL, text1)); + } + return diffs; + } + + // Trim off common prefix (speedup). + int commonlength = diff_commonPrefix(text1, text2); + String commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = diff_commonSuffix(text1, text2); + String commonsuffix = text1.substring(text1.length() - commonlength); + text1 = text1.substring(0, text1.length() - commonlength); + text2 = text2.substring(0, text2.length() - commonlength); + + // Compute the diff on the middle block. + diffs = diff_compute(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix.length() != 0) { + diffs.addFirst(new Diff(Operation.EQUAL, commonprefix)); + } + if (commonsuffix.length() != 0) { + diffs.addLast(new Diff(Operation.EQUAL, commonsuffix)); + } + + diff_cleanupMerge(diffs); + return diffs; + } + + /** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_compute(String text1, String text2, + boolean checklines, long deadline) { + LinkedList diffs = new LinkedList(); + + if (text1.length() == 0) { + // Just add some text (speedup). + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + if (text2.length() == 0) { + // Just delete some text (speedup). + diffs.add(new Diff(Operation.DELETE, text1)); + return diffs; + } + + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + int i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + Operation op = (text1.length() > text2.length()) ? + Operation.DELETE : Operation.INSERT; + diffs.add(new Diff(op, longtext.substring(0, i))); + diffs.add(new Diff(Operation.EQUAL, shorttext)); + diffs.add(new Diff(op, longtext.substring(i + shorttext.length()))); + return diffs; + } + + if (shorttext.length() == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + // Check to see if the problem can be split in two. + String[] hm = diff_halfMatch(text1, text2); + if (hm != null) { + // A half-match was found, sort out the return data. + String text1_a = hm[0]; + String text1_b = hm[1]; + String text2_a = hm[2]; + String text2_b = hm[3]; + String mid_common = hm[4]; + // Send both pairs off for separate processing. + LinkedList diffs_a = diff_main(text1_a, text2_a, + checklines, deadline); + LinkedList diffs_b = diff_main(text1_b, text2_b, + checklines, deadline); + // Merge the results. + diffs = diffs_a; + diffs.add(new Diff(Operation.EQUAL, mid_common)); + diffs.addAll(diffs_b); + return diffs; + } + + if (checklines && text1.length() > 100 && text2.length() > 100) { + return diff_lineMode(text1, text2, deadline); + } + + return diff_bisect(text1, text2, deadline); + } + + /** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_lineMode(String text1, String text2, + long deadline) { + // Scan the text on a line-by-line basis first. + LinesToCharsResult a = diff_linesToChars(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + List linearray = a.lineArray; + + LinkedList diffs = diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + diff_charsToLines(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.add(new Diff(Operation.EQUAL, "")); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + ListIterator pointer = diffs.listIterator(); + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + break; + case EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + pointer.previous(); + for (int j = 0; j < count_delete + count_insert; j++) { + pointer.previous(); + pointer.remove(); + } + for (Diff subDiff : diff_main(text_delete, text_insert, false, + deadline)) { + pointer.add(subDiff); + } + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + diffs.removeLast(); // Remove the dummy entry at the end. + + return diffs; + } + + /** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + protected LinkedList diff_bisect(String text1, String text2, + long deadline) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + int max_d = (text1_length + text2_length + 1) / 2; + int v_offset = max_d; + int v_length = 2 * max_d; + int[] v1 = new int[v_length]; + int[] v2 = new int[v_length]; + for (int x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + int delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will + // collide with the reverse path. + boolean front = (delta % 2 != 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + int k1start = 0; + int k1end = 0; + int k2start = 0; + int k2end = 0; + for (int d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if (System.currentTimeMillis() > deadline) { + break; + } + + // Walk the front path one step. + for (int k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + int k1_offset = v_offset + k1; + int x1; + if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + int y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length + && text1.charAt(x1) == text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + int k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { + // Mirror x2 onto top-left coordinate system. + int x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (int k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + int k2_offset = v_offset + k2; + int x2; + if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + int y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length + && text1.charAt(text1_length - x2 - 1) + == text2.charAt(text2_length - y2 - 1)) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + int k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { + int x1 = v1[k1_offset]; + int y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + LinkedList diffs = new LinkedList(); + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + /** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param x Index of split point in text1. + * @param y Index of split point in text2. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + private LinkedList diff_bisectSplit(String text1, String text2, + int x, int y, long deadline) { + String text1a = text1.substring(0, x); + String text2a = text2.substring(0, y); + String text1b = text1.substring(x); + String text2b = text2.substring(y); + + // Compute both diffs serially. + LinkedList diffs = diff_main(text1a, text2a, false, deadline); + LinkedList diffsb = diff_main(text1b, text2b, false, deadline); + + diffs.addAll(diffsb); + return diffs; + } + + /** + * Split two texts into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text1 First string. + * @param text2 Second string. + * @return An object containing the encoded text1, the encoded text2 and + * the List of unique strings. The zeroth element of the List of + * unique strings is intentionally blank. + */ + protected LinesToCharsResult diff_linesToChars(String text1, String text2) { + List lineArray = new ArrayList(); + Map lineHash = new HashMap(); + // e.g. linearray[4] == "Hello\n" + // e.g. linehash.get("Hello\n") == 4 + + // "\x00" is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray.add(""); + + // Allocate 2/3rds of the space for text1, the rest for text2. + String chars1 = diff_linesToCharsMunge(text1, lineArray, lineHash, 40000); + String chars2 = diff_linesToCharsMunge(text2, lineArray, lineHash, 65535); + return new LinesToCharsResult(chars1, chars2, lineArray); + } + + /** + * Split a text into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text String to encode. + * @param lineArray List of unique strings. + * @param lineHash Map of strings to indices. + * @param maxLines Maximum length of lineArray. + * @return Encoded string. + */ + private String diff_linesToCharsMunge(String text, List lineArray, + Map lineHash, int maxLines) { + int lineStart = 0; + int lineEnd = -1; + String line; + StringBuilder chars = new StringBuilder(); + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + while (lineEnd < text.length() - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length() - 1; + } + line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.containsKey(line)) { + chars.append(String.valueOf((char) (int) lineHash.get(line))); + } else { + if (lineArray.size() == maxLines) { + // Bail out at 65535 because + // String.valueOf((char) 65536).equals(String.valueOf(((char) 0))) + line = text.substring(lineStart); + lineEnd = text.length(); + } + lineArray.add(line); + lineHash.put(line, lineArray.size() - 1); + chars.append(String.valueOf((char) (lineArray.size() - 1))); + } + lineStart = lineEnd + 1; + } + return chars.toString(); + } + + /** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param diffs List of Diff objects. + * @param lineArray List of unique strings. + */ + protected void diff_charsToLines(List diffs, + List lineArray) { + StringBuilder text; + for (Diff diff : diffs) { + text = new StringBuilder(); + for (int j = 0; j < diff.text.length(); j++) { + text.append(lineArray.get(diff.text.charAt(j))); + } + diff.text = text.toString(); + } + } + + /** + * Determine the common prefix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the start of each string. + */ + public int diff_commonPrefix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int n = Math.min(text1.length(), text2.length()); + for (int i = 0; i < n; i++) { + if (text1.charAt(i) != text2.charAt(i)) { + return i; + } + } + return n; + } + + /** + * Determine the common suffix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of each string. + */ + public int diff_commonSuffix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int text1_length = text1.length(); + int text2_length = text2.length(); + int n = Math.min(text1_length, text2_length); + for (int i = 1; i <= n; i++) { + if (text1.charAt(text1_length - i) != text2.charAt(text2_length - i)) { + return i - 1; + } + } + return n; + } + + /** + * Determine if the suffix of one string is the prefix of another. + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of the first + * string and the start of the second string. + */ + protected int diff_commonOverlap(String text1, String text2) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + int text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1.equals(text2)) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + int best = 0; + int length = 1; + while (true) { + String pattern = text1.substring(text_length - length); + int found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length).equals( + text2.substring(0, length))) { + best = length; + length++; + } + } + } + + /** + * Do the two texts share a substring which is at least half the length of + * the longer text? + * This speedup can produce non-minimal diffs. + * @param text1 First string. + * @param text2 Second string. + * @return Five element String array, containing the prefix of text1, the + * suffix of text1, the prefix of text2, the suffix of text2 and the + * common middle. Or null if there was no match. + */ + protected String[] diff_halfMatch(String text1, String text2) { + if (Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + if (longtext.length() < 4 || shorttext.length() * 2 < longtext.length()) { + return null; // Pointless. + } + + // First check if the second quarter is the seed for a half-match. + String[] hm1 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 3) / 4); + // Check again based on the third quarter. + String[] hm2 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 1) / 2); + String[] hm; + if (hm1 == null && hm2 == null) { + return null; + } else if (hm2 == null) { + hm = hm1; + } else if (hm1 == null) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length() > hm2[4].length() ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + if (text1.length() > text2.length()) { + return hm; + //return new String[]{hm[0], hm[1], hm[2], hm[3], hm[4]}; + } else { + return new String[]{hm[2], hm[3], hm[0], hm[1], hm[4]}; + } + } + + /** + * Does a substring of shorttext exist within longtext such that the + * substring is at least half the length of longtext? + * @param longtext Longer string. + * @param shorttext Shorter string. + * @param i Start index of quarter length substring within longtext. + * @return Five element String array, containing the prefix of longtext, the + * suffix of longtext, the prefix of shorttext, the suffix of shorttext + * and the common middle. Or null if there was no match. + */ + private String[] diff_halfMatchI(String longtext, String shorttext, int i) { + // Start with a 1/4 length substring at position i as a seed. + String seed = longtext.substring(i, i + longtext.length() / 4); + int j = -1; + String best_common = ""; + String best_longtext_a = "", best_longtext_b = ""; + String best_shorttext_a = "", best_shorttext_b = ""; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + int prefixLength = diff_commonPrefix(longtext.substring(i), + shorttext.substring(j)); + int suffixLength = diff_commonSuffix(longtext.substring(0, i), + shorttext.substring(0, j)); + if (best_common.length() < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length() * 2 >= longtext.length()) { + return new String[]{best_longtext_a, best_longtext_b, + best_shorttext_a, best_shorttext_b, best_common}; + } else { + return null; + } + } + + /** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemantic(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of qualities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Number of characters that changed prior to the equality. + int length_insertions1 = 0; + int length_deletions1 = 0; + // Number of characters that changed after the equality. + int length_insertions2 = 0; + int length_deletions2 = 0; + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + equalities.push(thisDiff); + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = thisDiff.text; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.INSERT) { + length_insertions2 += thisDiff.text.length(); + } else { + length_deletions2 += thisDiff.text.length(); + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality != null && (lastEquality.length() + <= Math.max(length_insertions1, length_deletions1)) + && (lastEquality.length() + <= Math.max(length_insertions2, length_deletions2))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous equalities, walk back to the start. + while (pointer.hasPrevious()) { + pointer.previous(); + } + } else { + // There is a safe equality we can fall back to. + thisDiff = equalities.peek(); + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + } + + length_insertions1 = 0; // Reset the counters. + length_insertions2 = 0; + length_deletions1 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + // Normalize the diff. + if (changes) { + diff_cleanupMerge(diffs); + } + diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = diffs.listIterator(); + Diff prevDiff = null; + thisDiff = null; + if (pointer.hasNext()) { + prevDiff = pointer.next(); + if (pointer.hasNext()) { + thisDiff = pointer.next(); + } + } + while (thisDiff != null) { + if (prevDiff.operation == Operation.DELETE && + thisDiff.operation == Operation.INSERT) { + String deletion = prevDiff.text; + String insertion = thisDiff.text; + int overlap_length1 = this.diff_commonOverlap(deletion, insertion); + int overlap_length2 = this.diff_commonOverlap(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length() / 2.0 || + overlap_length1 >= insertion.length() / 2.0) { + // Overlap found. Insert an equality and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + insertion.substring(0, overlap_length1))); + prevDiff.text = + deletion.substring(0, deletion.length() - overlap_length1); + thisDiff.text = insertion.substring(overlap_length1); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } else { + if (overlap_length2 >= deletion.length() / 2.0 || + overlap_length2 >= insertion.length() / 2.0) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + deletion.substring(0, overlap_length2))); + prevDiff.operation = Operation.INSERT; + prevDiff.text = + insertion.substring(0, insertion.length() - overlap_length2); + thisDiff.operation = Operation.DELETE; + thisDiff.text = deletion.substring(overlap_length2); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + prevDiff = thisDiff; + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemanticLossless(LinkedList diffs) { + String equality1, edit, equality2; + String commonString; + int commonOffset; + int score, bestScore; + String bestEquality1, bestEdit, bestEquality2; + // Create a new iterator at the start. + ListIterator pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + Diff thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + equality1 = prevDiff.text; + edit = thisDiff.text; + equality2 = nextDiff.text; + + // First, shift the edit as far left as possible. + commonOffset = diff_commonSuffix(equality1, edit); + if (commonOffset != 0) { + commonString = edit.substring(edit.length() - commonOffset); + equality1 = equality1.substring(0, equality1.length() - commonOffset); + edit = commonString + edit.substring(0, edit.length() - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + bestScore = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + while (edit.length() != 0 && equality2.length() != 0 + && edit.charAt(0) == equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + score = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (!prevDiff.text.equals(bestEquality1)) { + // We have an improvement, save it back to the diff. + if (bestEquality1.length() != 0) { + prevDiff.text = bestEquality1; + } else { + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + pointer.next(); // Walk past nextDiff. + } + thisDiff.text = bestEdit; + if (bestEquality2.length() != 0) { + nextDiff.text = bestEquality2; + } else { + pointer.remove(); // Delete nextDiff. + nextDiff = thisDiff; + thisDiff = prevDiff; + } + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * @param one First string. + * @param two Second string. + * @return The score. + */ + private int diff_cleanupSemanticScore(String one, String two) { + if (one.length() == 0 || two.length() == 0) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + char char1 = one.charAt(one.length() - 1); + char char2 = two.charAt(0); + boolean nonAlphaNumeric1 = !Character.isLetterOrDigit(char1); + boolean nonAlphaNumeric2 = !Character.isLetterOrDigit(char2); + boolean whitespace1 = nonAlphaNumeric1 && Character.isWhitespace(char1); + boolean whitespace2 = nonAlphaNumeric2 && Character.isWhitespace(char2); + boolean lineBreak1 = whitespace1 + && Character.getType(char1) == Character.CONTROL; + boolean lineBreak2 = whitespace2 + && Character.getType(char2) == Character.CONTROL; + boolean blankLine1 = lineBreak1 && BLANKLINEEND.matcher(one).find(); + boolean blankLine2 = lineBreak2 && BLANKLINESTART.matcher(two).find(); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + // Define some regex patterns for matching boundaries. + private Pattern BLANKLINEEND + = Pattern.compile("\\n\\r?\\n\\Z", Pattern.DOTALL); + private Pattern BLANKLINESTART + = Pattern.compile("\\A\\r?\\n\\r?\\n", Pattern.DOTALL); + + /** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupEfficiency(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of equalities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Is there an insertion operation before the last equality. + boolean pre_ins = false; + // Is there a deletion operation before the last equality. + boolean pre_del = false; + // Is there an insertion operation after the last equality. + boolean post_ins = false; + // Is there a deletion operation after the last equality. + boolean post_del = false; + Diff thisDiff = pointer.next(); + Diff safeDiff = thisDiff; // The last Diff that is known to be unsplittable. + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + if (thisDiff.text.length() < Diff_EditCost && (post_ins || post_del)) { + // Candidate found. + equalities.push(thisDiff); + pre_ins = post_ins; + pre_del = post_del; + lastEquality = thisDiff.text; + } else { + // Not a candidate, and can never become one. + equalities.clear(); + lastEquality = null; + safeDiff = thisDiff; + } + post_ins = post_del = false; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality != null + && ((pre_ins && pre_del && post_ins && post_del) + || ((lastEquality.length() < Diff_EditCost / 2) + && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0) + + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(thisDiff = new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalities.clear(); + safeDiff = thisDiff; + } else { + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous questionable equalities, + // walk back to the last known safe diff. + thisDiff = safeDiff; + } else { + // There is an equality we can fall back to. + thisDiff = equalities.peek(); + } + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + post_ins = post_del = false; + } + + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupMerge(LinkedList diffs) { + diffs.add(new Diff(Operation.EQUAL, "")); // Add a dummy entry at the end. + ListIterator pointer = diffs.listIterator(); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + Diff thisDiff = pointer.next(); + Diff prevEqual = null; + int commonlength; + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + prevEqual = null; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + prevEqual = null; + break; + case EQUAL: + if (count_delete + count_insert > 1) { + boolean both_types = count_delete != 0 && count_insert != 0; + // Delete the offending records. + pointer.previous(); // Reverse direction. + while (count_delete-- > 0) { + pointer.previous(); + pointer.remove(); + } + while (count_insert-- > 0) { + pointer.previous(); + pointer.remove(); + } + if (both_types) { + // Factor out any common prefixies. + commonlength = diff_commonPrefix(text_insert, text_delete); + if (commonlength != 0) { + if (pointer.hasPrevious()) { + thisDiff = pointer.previous(); + assert thisDiff.operation == Operation.EQUAL + : "Previous diff should have been an equality."; + thisDiff.text += text_insert.substring(0, commonlength); + pointer.next(); + } else { + pointer.add(new Diff(Operation.EQUAL, + text_insert.substring(0, commonlength))); + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = diff_commonSuffix(text_insert, text_delete); + if (commonlength != 0) { + thisDiff = pointer.next(); + thisDiff.text = text_insert.substring(text_insert.length() + - commonlength) + thisDiff.text; + text_insert = text_insert.substring(0, text_insert.length() + - commonlength); + text_delete = text_delete.substring(0, text_delete.length() + - commonlength); + pointer.previous(); + } + } + // Insert the merged records. + if (text_delete.length() != 0) { + pointer.add(new Diff(Operation.DELETE, text_delete)); + } + if (text_insert.length() != 0) { + pointer.add(new Diff(Operation.INSERT, text_insert)); + } + // Step forward to the equality. + thisDiff = pointer.hasNext() ? pointer.next() : null; + } else if (prevEqual != null) { + // Merge this equality with the previous one. + prevEqual.text += thisDiff.text; + pointer.remove(); + thisDiff = pointer.previous(); + pointer.next(); // Forward direction + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + prevEqual = thisDiff; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + if (diffs.getLast().text.length() == 0) { + diffs.removeLast(); // Remove the dummy entry at the end. + } + + /* + * Second pass: look for single edits surrounded on both sides by equalities + * which can be shifted sideways to eliminate an equality. + * e.g: ABAC -> ABAC + */ + boolean changes = false; + // Create a new iterator at the start. + // (As opposed to walking the current one back.) + pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + if (thisDiff.text.endsWith(prevDiff.text)) { + // Shift the edit over the previous equality. + thisDiff.text = prevDiff.text + + thisDiff.text.substring(0, thisDiff.text.length() + - prevDiff.text.length()); + nextDiff.text = prevDiff.text + nextDiff.text; + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + thisDiff = pointer.next(); // Walk past nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } else if (thisDiff.text.startsWith(nextDiff.text)) { + // Shift the edit over the next equality. + prevDiff.text += nextDiff.text; + thisDiff.text = thisDiff.text.substring(nextDiff.text.length()) + + nextDiff.text; + pointer.remove(); // Delete nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. "The cat" vs "The big cat", 1->1, 5->8 + * @param diffs List of Diff objects. + * @param loc Location within text1. + * @return Location within text2. + */ + public int diff_xIndex(List diffs, int loc) { + int chars1 = 0; + int chars2 = 0; + int last_chars1 = 0; + int last_chars2 = 0; + Diff lastDiff = null; + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + // Equality or deletion. + chars1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + // Equality or insertion. + chars2 += aDiff.text.length(); + } + if (chars1 > loc) { + // Overshot the location. + lastDiff = aDiff; + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + if (lastDiff != null && lastDiff.operation == Operation.DELETE) { + // The location was deleted. + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); + } + + /** + * Convert a Diff list into a pretty HTML report. + * @param diffs List of Diff objects. + * @return HTML representation. + */ + public String diff_prettyHtml(List diffs) { + StringBuilder html = new StringBuilder(); + for (Diff aDiff : diffs) { + String text = aDiff.text.replace("&", "&").replace("<", "<") + .replace(">", ">").replace("\n", "¶
"); + switch (aDiff.operation) { + case INSERT: + html.append("").append(text) + .append(""); + break; + case DELETE: + html.append("").append(text) + .append(""); + break; + case EQUAL: + html.append("").append(text).append(""); + break; + } + } + return html.toString(); + } + + /** + * Compute and return the source text (all equalities and deletions). + * @param diffs List of Diff objects. + * @return Source text. + */ + public String diff_text1(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute and return the destination text (all equalities and insertions). + * @param diffs List of Diff objects. + * @return Destination text. + */ + public String diff_text2(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.DELETE) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param diffs List of Diff objects. + * @return Number of changes. + */ + public int diff_levenshtein(List diffs) { + int levenshtein = 0; + int insertions = 0; + int deletions = 0; + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + insertions += aDiff.text.length(); + break; + case DELETE: + deletions += aDiff.text.length(); + break; + case EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; + } + + /** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param diffs List of Diff objects. + * @return Delta text. + */ + public String diff_toDelta(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + try { + text.append("+").append(URLEncoder.encode(aDiff.text, "UTF-8") + .replace('+', ' ')).append("\t"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + break; + case DELETE: + text.append("-").append(aDiff.text.length()).append("\t"); + break; + case EQUAL: + text.append("=").append(aDiff.text.length()).append("\t"); + break; + } + } + String delta = text.toString(); + if (delta.length() != 0) { + // Strip off trailing tab character. + delta = delta.substring(0, delta.length() - 1); + delta = unescapeForEncodeUriCompatability(delta); + } + return delta; + } + + /** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param text1 Source string for the diff. + * @param delta Delta text. + * @return Array of Diff objects or null if invalid. + * @throws IllegalArgumentException If invalid input. + */ + public LinkedList diff_fromDelta(String text1, String delta) + throws IllegalArgumentException { + LinkedList diffs = new LinkedList(); + int pointer = 0; // Cursor in text1 + String[] tokens = delta.split("\t"); + for (String token : tokens) { + if (token.length() == 0) { + // Blank tokens are ok (from a trailing \t). + continue; + } + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + String param = token.substring(1); + switch (token.charAt(0)) { + case '+': + // decode would change all "+" to " " + param = param.replace("+", "%2B"); + try { + param = URLDecoder.decode(param, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in diff_fromDelta: " + param, e); + } + diffs.add(new Diff(Operation.INSERT, param)); + break; + case '-': + // Fall through. + case '=': + int n; + try { + n = Integer.parseInt(param); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid number in diff_fromDelta: " + param, e); + } + if (n < 0) { + throw new IllegalArgumentException( + "Negative number in diff_fromDelta: " + param); + } + String text; + try { + text = text1.substring(pointer, pointer += n); + } catch (StringIndexOutOfBoundsException e) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") larger than source text length (" + text1.length() + + ").", e); + } + if (token.charAt(0) == '=') { + diffs.add(new Diff(Operation.EQUAL, text)); + } else { + diffs.add(new Diff(Operation.DELETE, text)); + } + break; + default: + // Anything else is an error. + throw new IllegalArgumentException( + "Invalid diff operation in diff_fromDelta: " + token.charAt(0)); + } + } + if (pointer != text1.length()) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") smaller than source text length (" + text1.length() + ")."); + } + return diffs; + } + + + // MATCH FUNCTIONS + + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + public int match_main(String text, String pattern, int loc) { + // Check for null inputs. + if (text == null || pattern == null) { + throw new IllegalArgumentException("Null inputs. (match_main)"); + } + + loc = Math.max(0, Math.min(loc, text.length())); + if (text.equals(pattern)) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (text.length() == 0) { + // Nothing to match. + return -1; + } else if (loc + pattern.length() <= text.length() + && text.substring(loc, loc + pattern.length()).equals(pattern)) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return match_bitap(text, pattern, loc); + } + } + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + protected int match_bitap(String text, String pattern, int loc) { + assert (Match_MaxBits == 0 || pattern.length() <= Match_MaxBits) + : "Pattern too long for this application."; + + // Initialise the alphabet. + Map s = match_alphabet(pattern); + + // Highest score beyond which we give up. + double score_threshold = Match_Threshold; + // Is there a nearby exact match? (speedup) + int best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length()); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + } + } + + // Initialise the bit arrays. + int matchmask = 1 << (pattern.length() - 1); + best_loc = -1; + + int bin_min, bin_mid; + int bin_max = pattern.length() + text.length(); + // Empty initialization added to appease Java compiler. + int[] last_rd = new int[0]; + for (int d = 0; d < pattern.length(); d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at + // this error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore(d, loc + bin_mid, loc, pattern) + <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = (bin_max - bin_min) / 2 + bin_min; + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + int start = Math.max(1, loc - bin_mid + 1); + int finish = Math.min(loc + bin_mid, text.length()) + pattern.length(); + + int[] rd = new int[finish + 2]; + rd[finish + 1] = (1 << d) - 1; + for (int j = finish; j >= start; j--) { + int charMatch; + if (text.length() <= j - 1 || !s.containsKey(text.charAt(j - 1))) { + // Out of range. + charMatch = 0; + } else { + charMatch = s.get(text.charAt(j - 1)); + } + if (d == 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) + | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; + } + if ((rd[j] & matchmask) != 0) { + double score = match_bitapScore(d, j - 1, loc, pattern); + // This match will almost certainly be better than any existing + // match. But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + if (match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) { + // No hope for a (better) match at greater error levels. + break; + } + last_rd = rd; + } + return best_loc; + } + + /** + * Compute and return the score for a match with e errors and x location. + * @param e Number of errors in match. + * @param x Location of match. + * @param loc Expected location of match. + * @param pattern Pattern being sought. + * @return Overall score for match (0.0 = good, 1.0 = bad). + */ + private double match_bitapScore(int e, int x, int loc, String pattern) { + float accuracy = (float) e / pattern.length(); + int proximity = Math.abs(loc - x); + if (Match_Distance == 0) { + // Dodge divide by zero error. + return proximity == 0 ? accuracy : 1.0; + } + return accuracy + (proximity / (float) Match_Distance); + } + + /** + * Initialise the alphabet for the Bitap algorithm. + * @param pattern The text to encode. + * @return Hash of character locations. + */ + protected Map match_alphabet(String pattern) { + Map s = new HashMap(); + char[] char_pattern = pattern.toCharArray(); + for (char c : char_pattern) { + s.put(c, 0); + } + int i = 0; + for (char c : char_pattern) { + s.put(c, s.get(c) | (1 << (pattern.length() - i - 1))); + i++; + } + return s; + } + + + // PATCH FUNCTIONS + + + /** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param patch The patch to grow. + * @param text Source text. + */ + protected void patch_addContext(Patch patch, String text) { + if (text.length() == 0) { + return; + } + String pattern = text.substring(patch.start2, patch.start2 + patch.length1); + int padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while (text.indexOf(pattern) != text.lastIndexOf(pattern) + && pattern.length() < Match_MaxBits - Patch_Margin - Patch_Margin) { + padding += Patch_Margin; + pattern = text.substring(Math.max(0, patch.start2 - padding), + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + } + // Add one chunk for good luck. + padding += Patch_Margin; + + // Add the prefix. + String prefix = text.substring(Math.max(0, patch.start2 - padding), + patch.start2); + if (prefix.length() != 0) { + patch.diffs.addFirst(new Diff(Operation.EQUAL, prefix)); + } + // Add the suffix. + String suffix = text.substring(patch.start2 + patch.length1, + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + if (suffix.length() != 0) { + patch.diffs.addLast(new Diff(Operation.EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length(); + patch.start2 -= prefix.length(); + // Extend the lengths. + patch.length1 += prefix.length() + suffix.length(); + patch.length2 += prefix.length() + suffix.length(); + } + + /** + * Compute a list of patches to turn text1 into text2. + * A set of diffs will be computed. + * @param text1 Old text. + * @param text2 New text. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, String text2) { + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No diffs provided, compute our own. + LinkedList diffs = diff_main(text1, text2, true); + if (diffs.size() > 2) { + diff_cleanupSemantic(diffs); + diff_cleanupEfficiency(diffs); + } + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text1 will be derived from the provided diffs. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(LinkedList diffs) { + if (diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No origin string provided, compute our own. + String text1 = diff_text1(diffs); + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is ignored, diffs are the delta between text1 and text2. + * @param text1 Old text + * @param text2 Ignored. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + * @deprecated Prefer patch_make(String text1, LinkedList diffs). + */ + @Deprecated public LinkedList patch_make(String text1, String text2, + LinkedList diffs) { + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is not provided, diffs are the delta between text1 and text2. + * @param text1 Old text. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, LinkedList diffs) { + if (text1 == null || diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + + LinkedList patches = new LinkedList(); + if (diffs.isEmpty()) { + return patches; // Get rid of the null case. + } + Patch patch = new Patch(); + int char_count1 = 0; // Number of characters into the text1 string. + int char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + String prepatch_text = text1; + String postpatch_text = text1; + for (Diff aDiff : diffs) { + if (patch.diffs.isEmpty() && aDiff.operation != Operation.EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (aDiff.operation) { + case INSERT: + patch.diffs.add(aDiff); + patch.length2 += aDiff.text.length(); + postpatch_text = postpatch_text.substring(0, char_count2) + + aDiff.text + postpatch_text.substring(char_count2); + break; + case DELETE: + patch.length1 += aDiff.text.length(); + patch.diffs.add(aDiff); + postpatch_text = postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + aDiff.text.length()); + break; + case EQUAL: + if (aDiff.text.length() <= 2 * Patch_Margin + && !patch.diffs.isEmpty() && aDiff != diffs.getLast()) { + // Small equality inside a patch. + patch.diffs.add(aDiff); + patch.length1 += aDiff.text.length(); + patch.length2 += aDiff.text.length(); + } + + if (aDiff.text.length() >= 2 * Patch_Margin && !patch.diffs.isEmpty()) { + // Time for a new patch. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + patch = new Patch(); + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (aDiff.operation != Operation.INSERT) { + char_count1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + char_count2 += aDiff.text.length(); + } + } + // Pick up the leftover patch if not empty. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + } + + return patches; + } + + /** + * Given an array of patches, return another array that is identical. + * @param patches Array of Patch objects. + * @return Array of Patch objects. + */ + public LinkedList patch_deepCopy(LinkedList patches) { + LinkedList patchesCopy = new LinkedList(); + for (Patch aPatch : patches) { + Patch patchCopy = new Patch(); + for (Diff aDiff : aPatch.diffs) { + Diff diffCopy = new Diff(aDiff.operation, aDiff.text); + patchCopy.diffs.add(diffCopy); + } + patchCopy.start1 = aPatch.start1; + patchCopy.start2 = aPatch.start2; + patchCopy.length1 = aPatch.length1; + patchCopy.length2 = aPatch.length2; + patchesCopy.add(patchCopy); + } + return patchesCopy; + } + + /** + * Merge a set of patches onto the text. Return a patched text, as well + * as an array of true/false values indicating which patches were applied. + * @param patches Array of Patch objects + * @param text Old text. + * @return Two element Object array, containing the new text and an array of + * boolean values. + */ + public Object[] patch_apply(LinkedList patches, String text) { + if (patches.isEmpty()) { + return new Object[]{text, new boolean[0]}; + } + + // Deep copy the patches so that no changes are made to originals. + patches = patch_deepCopy(patches); + + String nullPadding = patch_addPadding(patches); + text = nullPadding + text + nullPadding; + patch_splitMax(patches); + + int x = 0; + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + int delta = 0; + boolean[] results = new boolean[patches.size()]; + for (Patch aPatch : patches) { + int expected_loc = aPatch.start2 + delta; + String text1 = diff_text1(aPatch.diffs); + int start_loc; + int end_loc = -1; + if (text1.length() > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = match_main(text, + text1.substring(0, this.Match_MaxBits), expected_loc); + if (start_loc != -1) { + end_loc = match_main(text, + text1.substring(text1.length() - this.Match_MaxBits), + expected_loc + text1.length() - this.Match_MaxBits); + if (end_loc == -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = match_main(text, text1, expected_loc); + } + if (start_loc == -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= aPatch.length2 - aPatch.length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + String text2; + if (end_loc == -1) { + text2 = text.substring(start_loc, + Math.min(start_loc + text1.length(), text.length())); + } else { + text2 = text.substring(start_loc, + Math.min(end_loc + this.Match_MaxBits, text.length())); + } + if (text1.equals(text2)) { + // Perfect match, just shove the replacement text in. + text = text.substring(0, start_loc) + diff_text2(aPatch.diffs) + + text.substring(start_loc + text1.length()); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + LinkedList diffs = diff_main(text1, text2, false); + if (text1.length() > this.Match_MaxBits + && diff_levenshtein(diffs) / (float) text1.length() + > this.Patch_DeleteThreshold) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + diff_cleanupSemanticLossless(diffs); + int index1 = 0; + for (Diff aDiff : aPatch.diffs) { + if (aDiff.operation != Operation.EQUAL) { + int index2 = diff_xIndex(diffs, index1); + if (aDiff.operation == Operation.INSERT) { + // Insertion + text = text.substring(0, start_loc + index2) + aDiff.text + + text.substring(start_loc + index2); + } else if (aDiff.operation == Operation.DELETE) { + // Deletion + text = text.substring(0, start_loc + index2) + + text.substring(start_loc + diff_xIndex(diffs, + index1 + aDiff.text.length())); + } + } + if (aDiff.operation != Operation.DELETE) { + index1 += aDiff.text.length(); + } + } + } + } + } + x++; + } + // Strip the padding off. + text = text.substring(nullPadding.length(), text.length() + - nullPadding.length()); + return new Object[]{text, results}; + } + + /** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param patches Array of Patch objects. + * @return The padding string added to each side. + */ + public String patch_addPadding(LinkedList patches) { + short paddingLength = this.Patch_Margin; + String nullPadding = ""; + for (short x = 1; x <= paddingLength; x++) { + nullPadding += String.valueOf((char) x); + } + + // Bump all the patches forward. + for (Patch aPatch : patches) { + aPatch.start1 += paddingLength; + aPatch.start2 += paddingLength; + } + + // Add some padding on start of first diff. + Patch patch = patches.getFirst(); + LinkedList diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getFirst().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addFirst(new Diff(Operation.EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getFirst().text.length()) { + // Grow first equality. + Diff firstDiff = diffs.getFirst(); + int extraLength = paddingLength - firstDiff.text.length(); + firstDiff.text = nullPadding.substring(firstDiff.text.length()) + + firstDiff.text; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches.getLast(); + diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getLast().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addLast(new Diff(Operation.EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getLast().text.length()) { + // Grow last equality. + Diff lastDiff = diffs.getLast(); + int extraLength = paddingLength - lastDiff.text.length(); + lastDiff.text += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; + } + + /** + * Look through the patches and break up any which are longer than the + * maximum limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param patches LinkedList of Patch objects. + */ + public void patch_splitMax(LinkedList patches) { + short patch_size = Match_MaxBits; + String precontext, postcontext; + Patch patch; + int start1, start2; + boolean empty; + Operation diff_type; + String diff_text; + ListIterator pointer = patches.listIterator(); + Patch bigpatch = pointer.hasNext() ? pointer.next() : null; + while (bigpatch != null) { + if (bigpatch.length1 <= Match_MaxBits) { + bigpatch = pointer.hasNext() ? pointer.next() : null; + continue; + } + // Remove the big old patch. + pointer.remove(); + start1 = bigpatch.start1; + start2 = bigpatch.start2; + precontext = ""; + while (!bigpatch.diffs.isEmpty()) { + // Create one of several smaller patches. + patch = new Patch(); + empty = true; + patch.start1 = start1 - precontext.length(); + patch.start2 = start2 - precontext.length(); + if (precontext.length() != 0) { + patch.length1 = patch.length2 = precontext.length(); + patch.diffs.add(new Diff(Operation.EQUAL, precontext)); + } + while (!bigpatch.diffs.isEmpty() + && patch.length1 < patch_size - Patch_Margin) { + diff_type = bigpatch.diffs.getFirst().operation; + diff_text = bigpatch.diffs.getFirst().text; + if (diff_type == Operation.INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + patch.diffs.addLast(bigpatch.diffs.removeFirst()); + empty = false; + } else if (diff_type == Operation.DELETE && patch.diffs.size() == 1 + && patch.diffs.getFirst().operation == Operation.EQUAL + && diff_text.length() > 2 * patch_size) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + empty = false; + patch.diffs.add(new Diff(diff_type, diff_text)); + bigpatch.diffs.removeFirst(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring(0, Math.min(diff_text.length(), + patch_size - patch.length1 - Patch_Margin)); + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + if (diff_type == Operation.EQUAL) { + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + } else { + empty = false; + } + patch.diffs.add(new Diff(diff_type, diff_text)); + if (diff_text.equals(bigpatch.diffs.getFirst().text)) { + bigpatch.diffs.removeFirst(); + } else { + bigpatch.diffs.getFirst().text = bigpatch.diffs.getFirst().text + .substring(diff_text.length()); + } + } + } + // Compute the head context for the next patch. + precontext = diff_text2(patch.diffs); + precontext = precontext.substring(Math.max(0, precontext.length() + - Patch_Margin)); + // Append the end context for this patch. + if (diff_text1(bigpatch.diffs).length() > Patch_Margin) { + postcontext = diff_text1(bigpatch.diffs).substring(0, Patch_Margin); + } else { + postcontext = diff_text1(bigpatch.diffs); + } + if (postcontext.length() != 0) { + patch.length1 += postcontext.length(); + patch.length2 += postcontext.length(); + if (!patch.diffs.isEmpty() + && patch.diffs.getLast().operation == Operation.EQUAL) { + patch.diffs.getLast().text += postcontext; + } else { + patch.diffs.add(new Diff(Operation.EQUAL, postcontext)); + } + } + if (!empty) { + pointer.add(patch); + } + } + bigpatch = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Take a list of patches and return a textual representation. + * @param patches List of Patch objects. + * @return Text representation of patches. + */ + public String patch_toText(List patches) { + StringBuilder text = new StringBuilder(); + for (Patch aPatch : patches) { + text.append(aPatch); + } + return text.toString(); + } + + /** + * Parse a textual representation of patches and return a List of Patch + * objects. + * @param textline Text representation of patches. + * @return List of Patch objects. + * @throws IllegalArgumentException If invalid input. + */ + public List patch_fromText(String textline) + throws IllegalArgumentException { + List patches = new LinkedList(); + if (textline.length() == 0) { + return patches; + } + List textList = Arrays.asList(textline.split("\n")); + LinkedList text = new LinkedList(textList); + Patch patch; + Pattern patchHeader + = Pattern.compile("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$"); + Matcher m; + char sign; + String line; + while (!text.isEmpty()) { + m = patchHeader.matcher(text.getFirst()); + if (!m.matches()) { + throw new IllegalArgumentException( + "Invalid patch string: " + text.getFirst()); + } + patch = new Patch(); + patches.add(patch); + patch.start1 = Integer.parseInt(m.group(1)); + if (m.group(2).length() == 0) { + patch.start1--; + patch.length1 = 1; + } else if (m.group(2).equals("0")) { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = Integer.parseInt(m.group(2)); + } + + patch.start2 = Integer.parseInt(m.group(3)); + if (m.group(4).length() == 0) { + patch.start2--; + patch.length2 = 1; + } else if (m.group(4).equals("0")) { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = Integer.parseInt(m.group(4)); + } + text.removeFirst(); + + while (!text.isEmpty()) { + try { + sign = text.getFirst().charAt(0); + } catch (IndexOutOfBoundsException e) { + // Blank line? Whatever. + text.removeFirst(); + continue; + } + line = text.getFirst().substring(1); + line = line.replace("+", "%2B"); // decode would change all "+" to " " + try { + line = URLDecoder.decode(line, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in patch_fromText: " + line, e); + } + if (sign == '-') { + // Deletion. + patch.diffs.add(new Diff(Operation.DELETE, line)); + } else if (sign == '+') { + // Insertion. + patch.diffs.add(new Diff(Operation.INSERT, line)); + } else if (sign == ' ') { + // Minor equality. + patch.diffs.add(new Diff(Operation.EQUAL, line)); + } else if (sign == '@') { + // Start of next patch. + break; + } else { + // WTF? + throw new IllegalArgumentException( + "Invalid patch mode '" + sign + "' in: " + line); + } + text.removeFirst(); + } + } + return patches; + } + + + /** + * Class representing one diff operation. + */ + public static class Diff { + /** + * One of: INSERT, DELETE or EQUAL. + */ + public Operation operation; + /** + * The text associated with this diff operation. + */ + public String text; + + /** + * Constructor. Initializes the diff with the provided values. + * @param operation One of INSERT, DELETE or EQUAL. + * @param text The text being applied. + */ + public Diff(Operation operation, String text) { + // Construct a diff with the specified operation and text. + this.operation = operation; + this.text = text; + } + + /** + * Display a human-readable version of this Diff. + * @return text version. + */ + public String toString() { + String prettyText = this.text.replace('\n', '\u00b6'); + return "Diff(" + this.operation + ",\"" + prettyText + "\")"; + } + + /** + * Create a numeric hash value for a Diff. + * This function is not used by DMP. + * @return Hash value. + */ + @Override + public int hashCode() { + final int prime = 31; + int result = (operation == null) ? 0 : operation.hashCode(); + result += prime * ((text == null) ? 0 : text.hashCode()); + return result; + } + + /** + * Is this Diff equivalent to another Diff? + * @param obj Another Diff to compare against. + * @return true or false. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Diff other = (Diff) obj; + if (operation != other.operation) { + return false; + } + if (text == null) { + if (other.text != null) { + return false; + } + } else if (!text.equals(other.text)) { + return false; + } + return true; + } + } + + + /** + * Class representing one patch operation. + */ + public static class Patch { + public LinkedList diffs; + public int start1; + public int start2; + public int length1; + public int length2; + + /** + * Constructor. Initializes with an empty list of diffs. + */ + public Patch() { + this.diffs = new LinkedList(); + } + + /** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return The GNU diff string. + */ + public String toString() { + String coords1, coords2; + if (this.length1 == 0) { + coords1 = this.start1 + ",0"; + } else if (this.length1 == 1) { + coords1 = Integer.toString(this.start1 + 1); + } else { + coords1 = (this.start1 + 1) + "," + this.length1; + } + if (this.length2 == 0) { + coords2 = this.start2 + ",0"; + } else if (this.length2 == 1) { + coords2 = Integer.toString(this.start2 + 1); + } else { + coords2 = (this.start2 + 1) + "," + this.length2; + } + StringBuilder text = new StringBuilder(); + text.append("@@ -").append(coords1).append(" +").append(coords2) + .append(" @@\n"); + // Escape the body of the patch with %xx notation. + for (Diff aDiff : this.diffs) { + switch (aDiff.operation) { + case INSERT: + text.append('+'); + break; + case DELETE: + text.append('-'); + break; + case EQUAL: + text.append(' '); + break; + } + try { + text.append(URLEncoder.encode(aDiff.text, "UTF-8").replace('+', ' ')) + .append("\n"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + } + return unescapeForEncodeUriCompatability(text.toString()); + } + } + + /** + * Unescape selected chars for compatability with JavaScript's encodeURI. + * In speed critical applications this could be dropped since the + * receiving application will certainly decode these fine. + * Note that this function is case-sensitive. Thus "%3f" would not be + * unescaped. But this is ok because it is only called with the output of + * URLEncoder.encode which returns uppercase hex. + * + * Example: "%3F" -> "?", "%24" -> "$", etc. + * + * @param str The string to escape. + * @return The escaped string. + */ + private static String unescapeForEncodeUriCompatability(String str) { + return str.replace("%21", "!").replace("%7E", "~") + .replace("%27", "'").replace("%28", "(").replace("%29", ")") + .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?") + .replace("%3A", ":").replace("%40", "@").replace("%26", "&") + .replace("%3D", "=").replace("%2B", "+").replace("%24", "$") + .replace("%2C", ",").replace("%23", "#"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 6ba09d5042..cb91616c06 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -7,6 +7,9 @@ import android.content.SharedPreferences; import android.util.Log; +import androidx.annotation.StringRes; +import android.os.Build; + import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; @@ -63,7 +66,22 @@ public class GlobalUserPreferences{ public static ColorPreference color; public static boolean likeIcon; - private static SharedPreferences getPrefs(){ + // MOSHIDON + public static boolean showDividers; + public static boolean relocatePublishButton; + public static boolean defaultToUnlistedReplies; + public static boolean doubleTapToSearch; + public static boolean doubleTapToSwipe; + public static boolean confirmBeforeReblog; + public static boolean hapticFeedback; + public static boolean replyLineAboveHeader; + public static boolean swapBookmarkWithBoostAction; + public static boolean loadRemoteAccountFollowers; + public static boolean mentionRebloggerAutomatically; + public static boolean showPostsWithoutAlt; + public static boolean showMediaPreview; + + public static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } @@ -125,6 +143,25 @@ public static void load(){ color=ColorPreference.valueOf(prefs.getString("color", MATERIAL3.name())); likeIcon=prefs.getBoolean("likeIcon", false); + // MOSHIDON + uniformNotificationIcon=prefs.getBoolean("uniformNotificationIcon", false); + showDividers =prefs.getBoolean("showDividers", false); + relocatePublishButton=prefs.getBoolean("relocatePublishButton", true); + defaultToUnlistedReplies=prefs.getBoolean("defaultToUnlistedReplies", false); + doubleTapToSearch =prefs.getBoolean("doubleTapToSearch", true); + doubleTapToSwipe =prefs.getBoolean("doubleTapToSwipe", true); + replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); + confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); + hapticFeedback=prefs.getBoolean("hapticFeedback", true); + swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false); + loadRemoteAccountFollowers=prefs.getBoolean("loadRemoteAccountFollowers", true); + mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false); + showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true); + showMediaPreview=prefs.getBoolean("showMediaPreview", true); + + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + + if (prefs.contains("prefixRepliesWithRe")) { prefixReplies = prefs.getBoolean("prefixRepliesWithRe", false) ? PrefixRepliesMode.TO_OTHERS : PrefixRepliesMode.NEVER; @@ -181,6 +218,23 @@ public static void save(){ .putBoolean("underlinedLinks", underlinedLinks) .putString("color", color.name()) .putBoolean("likeIcon", likeIcon) + + // MOSHIDON + .putBoolean("defaultToUnlistedReplies", defaultToUnlistedReplies) + .putBoolean("doubleTapToSearch", doubleTapToSearch) + .putBoolean("doubleTapToSwipe", doubleTapToSwipe) + .putBoolean("replyLineAboveHeader", replyLineAboveHeader) + .putBoolean("confirmBeforeReblog", confirmBeforeReblog) + .putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction) + .putBoolean("loadRemoteAccountFollowers", loadRemoteAccountFollowers) + .putBoolean("hapticFeedback", hapticFeedback) + .putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically) + .putBoolean("showDividers", showDividers) + .putBoolean("relocatePublishButton", relocatePublishButton) + .putBoolean("enableDeleteNotifications", enableDeleteNotifications) + .putBoolean("showPostsWithoutAlt", showPostsWithoutAlt) + .putBoolean("showMediaPreview", showMediaPreview) + .apply(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 005d9a382c..cb06d87a48 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -1,13 +1,20 @@ package org.joinmastodon.android; +import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PERMISSION_CODE; +import static org.joinmastodon.android.fragments.ComposeFragment.CAMERA_PIC_REQUEST_CODE; + import android.Manifest; +import android.app.Activity; import android.app.Fragment; import android.app.assist.AssistContent; import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.Uri; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.provider.MediaStore; import android.util.Log; import android.view.View; import android.widget.FrameLayout; @@ -17,6 +24,7 @@ import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.TakePictureRequestEvent; import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.ProfileFragment; @@ -201,6 +209,24 @@ public void onBackPressed() { } } +// @Override +// public void onActivityResult(int requestCode, int resultCode, Intent data){ +// if(requestCode==CAMERA_PIC_REQUEST_CODE && resultCode== Activity.RESULT_OK){ +// E.post(new TakePictureRequestEvent()); +// } +// } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == CAMERA_PERMISSION_CODE && (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + E.post(new TakePictureRequestEvent()); + } else { + Toast.makeText(this, R.string.permission_required, Toast.LENGTH_SHORT); + } + } + public Fragment getCurrentFragment() { for (int i = fragmentContainers.size() - 1; i >= 0; i--) { FrameLayout fl = fragmentContainers.get(i); diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index c6c549020c..11f3048d1b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -1,6 +1,8 @@ package org.joinmastodon.android; -import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.*; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.ALWAYS; +import static org.joinmastodon.android.GlobalUserPreferences.PrefixRepliesMode.TO_OTHERS; +import static org.joinmastodon.android.GlobalUserPreferences.getPrefs; import android.app.Notification; import android.app.NotificationChannel; @@ -12,12 +14,14 @@ import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; +import android.opengl.Visibility; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.requests.notifications.GetNotificationByID; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; @@ -32,6 +36,7 @@ import org.joinmastodon.android.model.PushNotification; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; @@ -136,9 +141,10 @@ public void onError(ErrorResponse error){ switch (NotificationAction.values()[intent.getIntExtra("notificationAction", 0)]) { case FAVORITE -> new SetStatusFavorited(statusID, true).exec(accountID); case BOOKMARK -> new SetStatusBookmarked(statusID, true).exec(accountID); - case REBLOG -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); - case UNDO_REBLOG -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); + case BOOST -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); + case UNBOOST -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); case REPLY -> handleReplyAction(context, accountID, intent, notification, notificationId, preferences); + case FOLLOW_BACK -> new SetAccountFollowed(notification.account.id, true, true, false).exec(accountID); default -> Log.w(TAG, "onReceive: Failed to get NotificationAction"); } } @@ -205,8 +211,8 @@ private void notify(Context context, PushNotification pn, String accountID, org. .setShowWhen(true) .setCategory(Notification.CATEGORY_SOCIAL) .setAutoCancel(true) - .setLights(UiUtils.getThemeColor(context, android.R.attr.colorAccent), 500, 1000) - .setColor(UiUtils.getThemeColor(context, android.R.attr.colorAccent)); + .setLights(context.getColor(R.color.primary_700), 500, 1000) + .setColor(context.getColor(R.color.shortcut_icon_background)); if (!GlobalUserPreferences.uniformNotificationIcon) { builder.setSmallIcon(switch (pn.notificationType) { @@ -252,14 +258,23 @@ private void notify(Context context, PushNotification pn, String accountID, org. builder.addAction(buildReplyAction(context, id, accountID, notification)); } builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_favorite), NotificationAction.FAVORITE)); - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); - if(notification.status.visibility != StatusPrivacy.DIRECT) { - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.REBLOG)); + if(GlobalUserPreferences.swapBookmarkWithBoostAction){ + if(notification.status.visibility != StatusPrivacy.DIRECT) { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.BOOST)); + }else{ + // This is just so there is a bookmark action if you cannot reblog the toot + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); + } + } else { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); } } case UPDATE -> { if(notification.status.reblogged) - builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNDO_REBLOG)); + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNBOOST)); + } + case FOLLOW -> { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.follow_back), NotificationAction.FOLLOW_BACK)); } } } @@ -323,7 +338,7 @@ private void handleReplyAction(Context context, String accountID, Intent intent, CreateStatus.Request req=new CreateStatus.Request(); req.status = initialText + input.toString(); req.language = notification.status.language; - req.visibility = notification.status.visibility; + req.visibility = (notification.status.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : notification.status.visibility); req.inReplyToId = notification.status.id; if (notification.status.hasSpoiler() && diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index c4183c878a..f62883b354 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -87,13 +87,13 @@ public void submitRequest(final MastodonAPIRequest req){ final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h)); thread.postRunnable(()->{ try{ - if (isBad) throw new IllegalArgumentException(); +// if (isBad) throw new IllegalArgumentException(); if(req.canceled) return; Request.Builder builder=new Request.Builder() .url(req.getURL().toString()) .method(req.getMethod(), req.getRequestBody()) - .header("User-Agent", "MegalodonAndroid/"+BuildConfig.VERSION_NAME); + .header("User-Agent", "MoshidonAndroid/"+BuildConfig.VERSION_NAME); String token=null; if(session!=null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java index 05f3d41565..0526ab79b5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java @@ -6,6 +6,7 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; 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.events.ReblogDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; @@ -26,6 +27,7 @@ public class StatusInteractionController{ private final HashMap runningFavoriteRequests=new HashMap<>(); private final HashMap runningReblogRequests=new HashMap<>(); private final HashMap runningBookmarkRequests=new HashMap<>(); + private final HashMap runningMuteRequests=new HashMap<>(); public StatusInteractionController(String accountID, boolean updateCounters) { this.accountID=accountID; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java new file mode 100644 index 0000000000..e9c9fe764a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetPrivateNote.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Relationship; + +public class SetPrivateNote extends MastodonAPIRequest{ + public SetPrivateNote(String id, String comment){ + super(MastodonAPIRequest.HttpMethod.POST, "/accounts/"+id+"/note", Relationship.class); + Request req = new Request(comment); + setRequestBody(req); + } + + private static class Request{ + public String comment; + public Request(String comment){ + this.comment=comment; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java new file mode 100644 index 0000000000..e2c8aa9cf4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetDomainBlocks.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.instance; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.DomainBlock; +import org.joinmastodon.android.model.ExtendedDescription; + +import java.util.List; + +public class GetDomainBlocks extends MastodonAPIRequest>{ + public GetDomainBlocks(){ + super(HttpMethod.GET, "/instance/domain_blocks", new TypeToken<>(){}); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java new file mode 100644 index 0000000000..5ce739a627 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetExtendedDescription.java @@ -0,0 +1,12 @@ +package org.joinmastodon.android.api.requests.instance; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ExtendedDescription; +import org.joinmastodon.android.model.Instance; + +public class GetExtendedDescription extends MastodonAPIRequest{ + public GetExtendedDescription(){ + super(HttpMethod.GET, "/instance/extended_description", ExtendedDescription.class); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java new file mode 100644 index 0000000000..87f74f9de4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetWeeklyActivity.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.instance; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.WeeklyActivity; + +import java.util.List; + +public class GetWeeklyActivity extends MastodonAPIRequest>{ + public GetWeeklyActivity(){ + super(HttpMethod.GET, "/instance/activity", new TypeToken<>(){}); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java new file mode 100644 index 0000000000..7c8e2e8521 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddList.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class AddList extends MastodonAPIRequest { + public AddList(String listName){ + super(HttpMethod.POST, "/lists", Object.class); + Request req = new Request(); + req.title = listName; + setRequestBody(req); + } + + public static class Request{ + public String title; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java new file mode 100644 index 0000000000..7a5d520588 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/EditListName.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class EditListName extends MastodonAPIRequest { + public EditListName(String newListName, String listId){ + super(HttpMethod.PUT, "/lists/"+listId, Object.class); + Request req = new Request(); + req.title = newListName; + setRequestBody(req); + } + + public static class Request{ + public String title; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java new file mode 100644 index 0000000000..4a14962a28 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveList.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.lists; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import java.util.List; + +public class RemoveList extends MastodonAPIRequest { + public RemoveList(String listId){ + super(HttpMethod.DELETE, "/lists/"+listId, Object.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java index ff83a55f02..5c2399774e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/DismissNotification.java @@ -10,8 +10,8 @@ import java.util.List; public class DismissNotification extends MastodonAPIRequest{ - public DismissNotification(String id){ - super(HttpMethod.POST, "/notifications/" + (id != null ? id + "/dismiss" : "clear"), Object.class); - setRequestBody(new Object()); - } + public DismissNotification(String id){ + super(HttpMethod.POST, "/notifications/" + (id != null ? id + "/dismiss" : "clear"), Object.class); + setRequestBody(new Object()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java index 5360153ef0..bb1e987130 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/oauth/CreateOAuthApp.java @@ -11,9 +11,9 @@ public CreateOAuthApp(){ } private static class Request{ - public String clientName="Megalodon"; + public String clientName="Moshidon"; public String redirectUris=AccountSessionManager.REDIRECT_URI; public String scopes=AccountSessionManager.SCOPE; - public String website="https://sk22.github.io/megalodon"; + public String website="https://github.com/LucasGGamerM/moshidon"; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java index e682cd02a3..608c8211c2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusEditHistory.java @@ -26,6 +26,8 @@ public void validateAndPostprocessResponse(List respObj, Response httpRe s.visibility=StatusPrivacy.PUBLIC; s.mentions=Collections.emptyList(); s.tags=Collections.emptyList(); + if (s.poll != null) + s.poll.id="fakeID"+i; i++; } super.validateAndPostprocessResponse(respObj, httpResponse); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java new file mode 100644 index 0000000000..6164b22442 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/SetStatusMuted.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class SetStatusMuted extends MastodonAPIRequest{ + public SetStatusMuted(String id, boolean muted){ + super(HttpMethod.POST, "/statuses/"+id+"/"+(muted ? "mute" : "unmute"), Status.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java index 2a7a0f3817..bd65665069 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -14,10 +14,15 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.TimelineDefinition; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; public class AccountLocalPreferences{ private final SharedPreferences prefs; @@ -50,6 +55,12 @@ public class AccountLocalPreferences{ private final static Type timelinesType=new TypeToken>() {}.getType(); private final static Type recentCustomEmojiType=new TypeToken>() {}.getType(); + // MOSHIDON +// private final static Type recentEmojisType = new TypeToken>() {}.getType(); +// public Map recentEmojis; + private final static Type notificationFiltersType = new TypeToken() {}.getType(); + public PushSubscription.Alerts notificationFilters; + public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ this.prefs=prefs; showInteractionCounts=prefs.getBoolean("interactionCounts", false); @@ -75,6 +86,10 @@ public AccountLocalPreferences(SharedPreferences prefs, AccountSession session){ showEmojiReactions=ShowEmojiReactions.valueOf(prefs.getString("showEmojiReactions", ShowEmojiReactions.HIDE_EMPTY.name())); color=prefs.contains("color") ? ColorPreference.valueOf(prefs.getString("color", null)) : null; recentCustomEmoji=fromJson(prefs.getString("recentCustomEmoji", null), recentCustomEmojiType, new ArrayList<>()); + + // MOSHIDON +// 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(){ @@ -114,6 +129,10 @@ public void save(){ .putString("showEmojiReactions", showEmojiReactions.name()) .putString("color", color!=null ? color.name() : null) .putString("recentCustomEmoji", gson.toJson(recentCustomEmoji)) + + // MOSHIDON +// .putString("recentEmojis", gson.toJson(recentEmojis)) + .putString("notificationFilters", gson.toJson(notificationFilters)) .apply(); } @@ -125,7 +144,9 @@ public enum ColorPreference{ BLUE, BROWN, RED, - YELLOW; + YELLOW, + NORD, + WHITE; public @StringRes int getName() { return switch(this){ @@ -137,6 +158,8 @@ public enum ColorPreference{ 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; }; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 56b411a6ef..ba90f5d80a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -61,7 +61,7 @@ public class AccountSessionManager{ private static final String TAG="AccountSessionManager"; public static final String SCOPE="read write follow push"; - public static final String REDIRECT_URI="megalodon-android-auth://callback"; + public static final String REDIRECT_URI = getRedirectURI(); private static final AccountSessionManager instance=new AccountSessionManager(); @@ -80,6 +80,17 @@ public static AccountSessionManager getInstance(){ return instance; } + public static String getRedirectURI() { + StringBuilder builder = new StringBuilder(); + builder.append("moshidon-android-"); + if (BuildConfig.BUILD_TYPE.equals("debug") || BuildConfig.BUILD_TYPE.equals("nightly")) { + builder.append(BuildConfig.BUILD_TYPE); + builder.append('-'); + } + builder.append("auth://callback"); + return builder.toString(); + } + private AccountSessionManager(){ prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE); File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); @@ -239,7 +250,7 @@ public void onSuccess(Application result){ .path("/oauth/authorize") .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", result.clientId) - .appendQueryParameter("redirect_uri", "megalodon-android-auth://callback") + .appendQueryParameter("redirect_uri", REDIRECT_URI) .appendQueryParameter("scope", SCOPE) .build(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java new file mode 100644 index 0000000000..a0a1908e2f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusMuteChangedEvent.java @@ -0,0 +1,15 @@ +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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java new file mode 100644 index 0000000000..4ce29381a0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/TakePictureRequestEvent.java @@ -0,0 +1,6 @@ +package org.joinmastodon.android.events; + +public class TakePictureRequestEvent { + public TakePictureRequestEvent(){ + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index a73d7d7228..26a8e65492 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -16,6 +16,10 @@ import android.widget.ImageButton; import android.widget.Toolbar; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; @@ -23,6 +27,7 @@ import org.joinmastodon.android.api.requests.polls.SubmitPollVote; 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.model.Account; import org.joinmastodon.android.model.DisplayItemsParent; @@ -41,6 +46,7 @@ import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; 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.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; @@ -48,6 +54,7 @@ import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; 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.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.TypedObjectPool; @@ -68,6 +75,7 @@ import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -87,6 +95,8 @@ public abstract class BaseStatusListFragment exten protected HashMap relationships=new HashMap<>(); protected Rect tmpRect=new Rect(); protected TypedObjectPool attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView); + protected TypedObjectPool previewlessAttachmentViewsPool=new TypedObjectPool<>(this::makeNewPreviewlessMediaAttachmentView); + protected boolean currentlyScrolling; protected String maxID; @@ -286,6 +296,79 @@ private MediaAttachmentViewController findPhotoViewHolder(int index){ }); } + + public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){ + final Status status=_status.getContentStatus(); + currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, 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 public @Nullable View getFab() { if (getParentFragment() instanceof HasFab l) return l.getFab(); @@ -493,7 +576,7 @@ protected void updatePoll(String itemID, Status status, Poll poll){ spoilerFooterIndex=spoilerItem.contentItems.indexOf(pollItems.get(pollItems.size()-1)); } pollItems.clear(); - StatusDisplayItem.buildPollItems(itemID, this, poll, pollItems); + StatusDisplayItem.buildPollItems(itemID, this, poll, pollItems, status); if(spoilerItem!=null){ spoilerItem.contentItems.subList(spoilerFirstOptionIndex, spoilerFooterIndex+1).clear(); spoilerItem.contentItems.addAll(spoilerFirstOptionIndex, pollItems); @@ -828,10 +911,18 @@ private MediaAttachmentViewController makeNewMediaAttachmentView(MediaGridStatus return new MediaAttachmentViewController(getActivity(), type); } + private PreviewlessMediaAttachmentViewController makeNewPreviewlessMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){ + return new PreviewlessMediaAttachmentViewController(getActivity(), type); + } + public TypedObjectPool getAttachmentViewsPool(){ return attachmentViewsPool; } + public TypedObjectPool getPreviewlessAttachmentViewsPool(){ + return previewlessAttachmentViewsPool; + } + @Override public void onProvideAssistContent(AssistContent assistContent) { assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); @@ -858,13 +949,7 @@ public void onSuccess(Translation result){ return; status.translation=result; status.translationState=Status.TranslationState.SHOWN; - 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); - } + updateTranslation(itemID); } @Override @@ -872,12 +957,7 @@ public void onError(ErrorResponse error){ if(getActivity()==null) return; status.translationState=Status.TranslationState.HIDDEN; - TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); - if(text!=null){ - text.updateTranslation(true); - }else{ - notifyItemChanged(itemID, TextStatusDisplayItem.class); - } + updateTranslation(itemID); new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.error) .setMessage(R.string.translation_failed) @@ -889,6 +969,10 @@ public void onError(ErrorResponse error){ } } } + updateTranslation(itemID); + } + + private void updateTranslation(String itemID) { TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); if(text!=null){ text.updateTranslation(true); @@ -896,6 +980,22 @@ public void onError(ErrorResponse error){ }else{ notifyItemChanged(itemID, TextStatusDisplayItem.class); } + + 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(); + } + + for(int i=0;i { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); + publishButtonRelocated.setVisibility(View.VISIBLE); + + draftsBtn=view.findViewById(R.id.drafts_btn); + draftsBtn.setVisibility(View.VISIBLE); + } else { + charCounter=view.findViewById(R.id.char_counter); + charCounter.setVisibility(View.VISIBLE); + charCounter.setText(String.valueOf(charLimit)); + } + mainLayout=view.findViewById(R.id.compose_main_ll); mainEditText=view.findViewById(R.id.toot_text); mainEditTextWrap=view.findViewById(R.id.toot_text_wrap); - charCounter=view.findViewById(R.id.char_counter); - charCounter.setText(String.valueOf(charLimit)); scrollView=view.findViewById(R.id.scroll_view); selfName=view.findViewById(R.id.self_name); @@ -353,19 +391,27 @@ public void getOutline(View view, Outline outline){ sensitiveBtn=view.findViewById(R.id.sensitive_item); replyText=view.findViewById(R.id.reply_text); - if (UiUtils.isPhotoPickerAvailable()) { - PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); - attachPopup.inflate(R.menu.attach); - attachPopup.setOnMenuItemClickListener(i -> { + PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); + attachPopup.inflate(R.menu.attach); + if(UiUtils.isPhotoPickerAvailable()) + attachPopup.getMenu().findItem(R.id.media).setVisible(true); + + attachPopup.setOnMenuItemClickListener(i -> { + if (i.getItemId() == R.id.camera){ + try { + openCamera(); + } catch (IOException e){ + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT); + } + + } else { openFilePicker(i.getItemId() == R.id.media); - return true; - }); - UiUtils.enablePopupMenuIcons(getContext(), attachPopup); - mediaBtn.setOnClickListener(v->attachPopup.show()); - mediaBtn.setOnTouchListener(attachPopup.getDragToOpenListener()); - } else { - mediaBtn.setOnClickListener(v -> openFilePicker(false)); - } + } + return true; + }); + UiUtils.enablePopupMenuIcons(getContext(), attachPopup); + mediaBtn.setOnClickListener(v->attachPopup.show()); + mediaBtn.setOnTouchListener(attachPopup.getDragToOpenListener()); if (isInstancePixelfed()) pollBtn.setVisibility(View.GONE); pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); @@ -512,6 +558,19 @@ public void onSaveInstanceState(Bundle outState){ } } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == CAMERA_PERMISSION_CODE && (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + startActivityForResult(cameraIntent, CAMERA_PIC_REQUEST_CODE); + } else { + Toast.makeText(getContext(), R.string.permission_required, Toast.LENGTH_SHORT); + } + } + @Override public void onResume(){ super.onResume(); @@ -712,11 +771,17 @@ public void getOutline(View view, Outline outline){ replyText.setOnClickListener(v->{ scrollView.smoothScrollTo(0, 0); }); + replyText.setOnClickListener(v->{ + scrollView.smoothScrollTo(0, 0); + }); + ArrayList mentions=new ArrayList<>(); String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; if(!status.account.id.equals(ownID)) mentions.add('@'+status.account.acct); + if(status.rebloggedBy != null && GlobalUserPreferences.mentionRebloggerAutomatically) + mentions.add('@'+status.rebloggedBy.acct); for(Mention mention:status.mentions){ if(mention.id.equals(ownID)) continue; @@ -807,7 +872,25 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ actionItem.setActionView(wrap); actionItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - draftsBtn = wrap.findViewById(R.id.drafts_btn); + if(!GlobalUserPreferences.relocatePublishButton){ + publishButton = wrap.findViewById(R.id.publish_btn); + publishButton.setOnClickListener(v -> { + if(GlobalUserPreferences.altTextReminders && editingStatus==null) + checkAltTextsAndPublish(); + else + publish(); + }); + publishButton.setVisibility(View.VISIBLE); + + draftsBtn = wrap.findViewById(R.id.drafts_btn); + draftsBtn.setVisibility(View.VISIBLE); + }else{ + charCounter = wrap.findViewById(R.id.char_counter); + charCounter.setVisibility(View.VISIBLE); + charCounter.setText(String.valueOf(charLimit)); + } + +// draftsBtn = wrap.findViewById(R.id.drafts_btn); draftOptionsPopup = new PopupMenu(getContext(), draftsBtn); draftOptionsPopup.inflate(R.menu.compose_more); draftMenuItem = draftOptionsPopup.getMenu().findItem(R.id.draft); @@ -824,7 +907,7 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ }); UiUtils.enablePopupMenuIcons(getContext(), draftOptionsPopup); - publishButton = wrap.findViewById(R.id.publish_btn); + languageButton = wrap.findViewById(R.id.language_btn); languageButton.setOnClickListener(v->showLanguageAlert()); languageButton.setOnLongClickListener(v->{ @@ -834,9 +917,8 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ } return false; }); - publishButton.post(()->publishButton.setMinimumWidth(publishButton.getWidth())); - publishButton.setOnClickListener(v->{ + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setOnClickListener(v->{ Consumer draftCheckComplete=(isDraft)->{ if(GlobalUserPreferences.altTextReminders && !isDraft) checkAltTextsAndPublish(); else publish(); @@ -944,6 +1026,9 @@ private void updateCharCounter(){ private void resetPublishButtonText() { int publishText = editingStatus==null || redraftStatus ? R.string.publish : R.string.save; + if(GlobalUserPreferences.relocatePublishButton){ + return; + } AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences(); if (publishText == R.string.publish && !TextUtils.isEmpty(prefs.publishButtonText)) { publishButton.setText(prefs.publishButtonText); @@ -954,6 +1039,10 @@ private void resetPublishButtonText() { public void updatePublishButtonState(){ uuid=null; + if(GlobalUserPreferences.relocatePublishButton && publishButtonRelocated != null){ + publishButtonRelocated.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); + } + if(publishButton==null) return; publishButton.setEnabled((!isInstancePixelfed() || !mediaViewController.isEmpty()) && (trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); @@ -1061,7 +1150,7 @@ private void publish(){ overlayParams.token=mainEditText.getWindowToken(); wm.addView(sendingOverlay, overlayParams); - publishButton.setEnabled(false); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(false); V.setVisibilityAnimated(sendProgress, View.VISIBLE); mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError); @@ -1197,7 +1286,7 @@ private void handlePublishError(ErrorResponse error){ wm.removeView(sendingOverlay); sendingOverlay=null; V.setVisibilityAnimated(sendProgress, View.GONE); - publishButton.setEnabled(true); + (GlobalUserPreferences.relocatePublishButton ? publishButtonRelocated : publishButton).setEnabled(true); if(error instanceof MastodonErrorResponse me){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.post_failed) @@ -1353,6 +1442,39 @@ public void onActivityResult(int requestCode, int resultCode, Intent data){ } } } + + if(requestCode==CAMERA_PIC_REQUEST_CODE && resultCode==Activity.RESULT_OK){ + onAddMediaAttachmentFromEditText(photoUri, null); + } + } + + @Subscribe + public void onTakePictureRequest(TakePictureRequestEvent ev) { + if(isVisible()) { + try { + openCamera(); + } catch (IOException e) { + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT); + } + + } + } + + private void openCamera() throws IOException { + if (getContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + File photoFile = File.createTempFile("img", ".jpg"); + photoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", photoFile); + + Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); + if(getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)){ + startActivityForResult(cameraIntent, CAMERA_PIC_REQUEST_CODE); + } else { + Toast.makeText(getContext(), R.string.mo_camera_not_available, Toast.LENGTH_SHORT); + } + } else { + getActivity().requestPermissions(new String[]{Manifest.permission.CAMERA}, CAMERA_PERMISSION_CODE); + } } @@ -1392,7 +1514,7 @@ private void toggleSensitive() { public void updateSensitive() { sensitiveBtn.setVisibility(View.GONE); - if (!mediaViewController.isEmpty() && !hasSpoiler) sensitiveBtn.setVisibility(View.VISIBLE); + if (!mediaViewController.isEmpty()) sensitiveBtn.setVisibility(View.VISIBLE); if (mediaViewController.isEmpty()) sensitive = false; } @@ -1429,9 +1551,15 @@ private void updateScheduledAt(Instant scheduledAt) { scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_draft)); } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_draft)); - draftsBtn.setImageResource(R.drawable.ic_fluent_drafts_20_filled); - publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) - ? R.string.save : R.string.sk_draft); + draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_drafts_24_regular : R.drawable.ic_fluent_drafts_20_filled)); + + if(GlobalUserPreferences.relocatePublishButton){ + publishButtonRelocated.setImageResource(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.drawable.ic_fluent_save_24_selector : R.drawable.ic_fluent_drafts_24_selector); + }else{ + publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.string.save : R.string.sk_draft); + } } else { scheduleMenuItem.setVisible(false); unscheduleMenuItem.setVisible(true); @@ -1444,12 +1572,21 @@ private void updateScheduledAt(Instant scheduledAt) { scheduleDraftDismiss.setTooltipText(getString(R.string.sk_compose_no_schedule)); } scheduleDraftDismiss.setContentDescription(getString(R.string.sk_compose_no_schedule)); - draftsBtn.setImageResource(R.drawable.ic_fluent_clock_20_filled); - publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.equals(scheduledAt) - ? R.string.save : R.string.sk_schedule); + draftsBtn.setImageDrawable(getContext().getDrawable(GlobalUserPreferences.relocatePublishButton ? R.drawable.ic_fluent_clock_24_filled : R.drawable.ic_fluent_clock_20_filled)); + if(GlobalUserPreferences.relocatePublishButton) + { + publishButtonRelocated.setImageResource(scheduledStatus != null && scheduledStatus.scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) + ? R.drawable.ic_fluent_save_24_selector : R.drawable.ic_fluent_clock_24_selector); + }else{ + publishButton.setText(scheduledStatus != null && scheduledStatus.scheduledAt.equals(scheduledAt) + ? R.string.save : R.string.sk_schedule); + } } } else { - draftsBtn.setImageResource(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){ + publishButtonRelocated.setImageResource(R.drawable.ic_fluent_send_24_regular); + } resetPublishButtonText(); } } @@ -1559,8 +1696,10 @@ private void onVisibilityClick(View v){ menu.show(); } - private void loadDefaultStatusVisibility(Bundle savedInstanceState){ - if(replyTo != null) statusVisibility = replyTo.visibility; + private void loadDefaultStatusVisibility(Bundle savedInstanceState) { + if(replyTo != null) { + statusVisibility = (replyTo.visibility == StatusPrivacy.PUBLIC && GlobalUserPreferences.defaultToUnlistedReplies ? StatusPrivacy.UNLISTED : replyTo.visibility); + } AccountSessionManager asm = AccountSessionManager.getInstance(); Preferences prefs=asm.getAccount(accountID).preferences; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java new file mode 100644 index 0000000000..2f499280ed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/CustomLocalTimelineFragment.java @@ -0,0 +1,97 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.net.Uri; +import android.view.Menu; +import android.view.MenuInflater; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.TimelineDefinition; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; +import org.joinmastodon.android.utils.StatusFilterPredicate; + +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; + +public class CustomLocalTimelineFragment extends PinnableStatusListFragment implements ProvidesAssistContent.ProvidesWebUri{ + // private String name; + private String domain; + + private String maxID; + @Override + protected boolean wantsComposeButton() { + return false; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + domain=getArguments().getString("domain"); + updateTitle(domain); + + setHasOptionsMenu(true); + } + + private void updateTitle(String domain) { + this.domain = domain; + setTitle(this.domain); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count, getLocalPrefs().timelineReplyVisibility) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + if(!result.isEmpty()) + maxID=result.get(result.size()-1).id; + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList()); + result.stream().forEach(status -> { + status.account.acct += "@"+domain; + status.mentions.forEach(mention -> mention.id = null); + status.isRemote = true; + }); + + onDataLoaded(result, !result.isEmpty()); + } + }) + .execNoAuth(domain); + } + + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) + loadData(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.custom_local_timelines, menu); + super.onCreateOptionsMenu(menu, inflater); + UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin); + } + + @Override + protected FilterContext getFilterContext() { + return null; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(domain); + } + + @Override + protected TimelineDefinition makeTimelineDefinition() { + return TimelineDefinition.ofCustomLocalTimeline(domain); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 9524aa7eba..3243244df6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -8,6 +8,7 @@ import android.app.AlertDialog; import android.content.Context; import android.os.Bundle; +import android.text.InputType; import android.text.TextUtils; import android.view.Menu; import android.view.MenuInflater; @@ -18,6 +19,7 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; @@ -39,6 +41,9 @@ import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; import org.joinmastodon.android.api.session.AccountLocalPreferences; 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.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.ListTimeline; @@ -46,6 +51,7 @@ import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.TextInputFrameLayout; import java.util.ArrayList; import java.util.Collections; @@ -58,6 +64,7 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; public class EditTimelinesFragment extends MastodonRecyclerFragment implements ScrollableToTop { @@ -70,6 +77,7 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment listTimelines = new ArrayList<>(); private final List hashtags = new ArrayList<>(); private MenuItem addHashtagItem; + private final List localTimelines = new ArrayList<>(); public EditTimelinesFragment() { super(10); @@ -138,6 +146,10 @@ public boolean onOptionsItemSelected(MenuItem item) { optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0); return true; } + if (item.getItemId() == R.id.menu_add_local_timelines) { + addNewLocalTimeline(); + return true; + } TimelineDefinition tl = timelineByMenuItem.get(item); if (tl != null) { addTimeline(tl); @@ -156,6 +168,26 @@ private void addTimeline(TimelineDefinition tl) { updateOptionsMenu(); } + private void addNewLocalTimeline() { + FrameLayout inputWrap = new FrameLayout(getContext()); + EditText input = new EditText(getContext()); + input.setHint(R.string.sk_example_domain); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(V.dp(16), V.dp(4), V.dp(16), V.dp(16)); + input.setLayoutParams(params); + inputWrap.addView(input); + new M3AlertDialogBuilder(getContext()).setTitle(R.string.mo_add_custom_server_local_timeline).setView(inputWrap) + .setPositiveButton(R.string.save, (d, which) -> { + TimelineDefinition tl = TimelineDefinition.ofCustomLocalTimeline(input.getText().toString().trim()); + data.add(tl); + saveTimelines(); + }) + .setNegativeButton(R.string.cancel, (d, which) -> { + }) + .show(); + } + private void addTimelineToOptions(TimelineDefinition tl, Menu menu) { if (data.contains(tl)) return; MenuItem item = addOptionsItem(menu, tl.getTitle(getContext()), tl.getIcon().iconRes); @@ -184,6 +216,9 @@ private void updateOptionsMenu() { SubMenu hashtagsMenu = menu.addSubMenu(R.string.sk_hashtag); 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); + addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular); + makeBackItem(timelinesMenu); makeBackItem(listsMenu); makeBackItem(hashtagsMenu); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java index ccb86fdc5a..22739fc6d7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -72,6 +72,7 @@ protected RecyclerView.Adapter getAdapter() { return new HashtagsAdapter(); } + @Override public void scrollToTop() { smoothScrollRecyclerViewToTop(list); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 5a23e2a442..705d2738e5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -16,13 +16,22 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonErrorResponse; +import org.joinmastodon.android.api.requests.filters.CreateFilter; +import org.joinmastodon.android.api.requests.filters.DeleteFilter; +import org.joinmastodon.android.api.requests.filters.GetFilters; import org.joinmastodon.android.api.requests.tags.GetTag; import org.joinmastodon.android.api.requests.tags.SetTagFollowed; import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.FilterContext; +import org.joinmastodon.android.model.FilterKeyword; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; @@ -31,10 +40,12 @@ import org.joinmastodon.android.ui.views.ProgressBarButton; import org.parceler.Parcels; +import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -49,7 +60,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ private TextView headerTitle, headerSubtitle; private ProgressBarButton followButton; private ProgressBar followProgress; - private MenuItem followMenuItem, pinMenuItem; + private MenuItem followMenuItem, pinMenuItem, muteMenuItem; private boolean followRequestRunning; private boolean toolbarContentVisible; @@ -61,6 +72,8 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{ private Menu optionsMenu; private MenuInflater optionsMenuInflater; + private Optional filter = Optional.empty(); + @Override protected boolean wantsComposeButton() { return true; @@ -84,6 +97,56 @@ public void onAttach(Activity activity){ setHasOptionsMenu(true); } + private void updateMuteState(boolean newMute) { + muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtag)); + muteMenuItem.setIcon(newMute ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); + } + + private void showMuteDialog(boolean mute) { + UiUtils.showConfirmationAlert(getContext(), + mute ? R.string.mo_unmute_hashtag : R.string.mo_mute_hashtag, + mute ? R.string.mo_confirm_to_unmute_hashtag : R.string.mo_confirm_to_mute_hashtag, + mute ? R.string.do_unmute : R.string.do_mute, + mute ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular, + mute ? this::unmuteHashtag : this::muteHashtag + ); + } + private void unmuteHashtag() { + //safe to get, this only called if filter is present + new DeleteFilter(filter.get().id).setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + filter=Optional.empty(); + updateMuteState(false); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).exec(accountID); + } + + private void muteHashtag() { + FilterKeyword hashtagFilter=new FilterKeyword(); + hashtagFilter.wholeWord=true; + hashtagFilter.keyword="#"+hashtagName; + new CreateFilter("#"+hashtagName, EnumSet.of(FilterContext.HOME), FilterAction.HIDE, 0 , List.of(hashtagFilter)).setCallback(new Callback<>(){ + @Override + public void onSuccess(Filter result){ + filter=Optional.of(result); + updateMuteState(true); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getContext()); + } + }).exec(accountID); + } + + + @Override protected TimelineDefinition makeTimelineDefinition() { return TimelineDefinition.ofHashtag(hashtagName); @@ -141,6 +204,11 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ }); } + @Override + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtagName+' '); + } + @Override public void onFabClick(View v){ Bundle args=new Bundle(); @@ -221,13 +289,24 @@ private void createOptionsMenu(){ followMenuItem=optionsMenu.findItem(R.id.follow_hashtag); pinMenuItem=optionsMenu.findItem(R.id.pin); followMenuItem.setVisible(toolbarContentVisible); - pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS); +// pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS); super.updatePinButton(pinMenuItem); - if(toolbarContentVisible){ - UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu); - }else{ - UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.pin); - } + + muteMenuItem = optionsMenu.findItem(R.id.mute_hashtag); + updateMuteState(filter.isPresent()); + new GetFilters().setCallback(new Callback<>() { + @Override + public void onSuccess(List filters) { + if (getActivity() == null) return; + filter=filters.stream().filter(filter->filter.title.equals("#"+hashtagName)).findAny(); + updateMuteState(filter.isPresent()); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(accountID); } @Override @@ -250,6 +329,9 @@ public boolean onOptionsItemSelected(MenuItem item){ if (super.onOptionsItemSelected(item)) return true; if (item.getItemId() == R.id.follow_hashtag && hashtag!=null) { setFollowed(!hashtag.following); + } else if (item.getItemId() == R.id.mute_hashtag) { + showMuteDialog(filter.isPresent()); + return true; } return true; } @@ -305,7 +387,10 @@ private void updateHeader(){ if(followMenuItem!=null){ followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); followMenuItem.setIcon(hashtag.following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular); - UiUtils.insetPopupMenuIcon(getContext(), followMenuItem); + } + if(muteMenuItem!=null){ + muteMenuItem.setTitle(getString(filter.isPresent() ? R.string.unmute_user : R.string.mute_user, "#" + hashtag)); + muteMenuItem.setIcon(filter.isPresent() ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 1ddf727ae5..78b8a33942 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -5,6 +5,8 @@ import android.app.NotificationManager; import android.app.assist.AssistContent; import android.graphics.drawable.RippleDrawable; +import android.content.Intent; +import android.graphics.Outline; import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; @@ -31,6 +33,7 @@ import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent; import org.joinmastodon.android.fragments.discover.DiscoverFragment; +import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; @@ -46,6 +49,7 @@ import java.util.List; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.AppKitFragment; @@ -75,7 +79,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); accountID=getArguments().getString("account"); - setTitle(R.string.sk_app_name); + setTitle(R.string.mo_app_name); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); @@ -260,8 +264,11 @@ public void setCurrentTab(@IdRes int tab){ private void onTabSelected(@IdRes int tab){ Fragment newFragment=fragmentForTab(tab); - if(tab==currentTab && newFragment instanceof ScrollableToTop scrollable) { - scrollable.scrollToTop(); + if(tab==currentTab){ + if (tab == R.id.tab_search && GlobalUserPreferences.doubleTapToSearch) + discoverFragment.openSearch(); + else if(newFragment instanceof ScrollableToTop scrollable) + scrollable.scrollToTop(); return; } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); @@ -296,12 +303,20 @@ private boolean onTabLongClick(@IdRes int tab){ } new AccountSwitcherSheet(getActivity(), this).show(); return true; - } else if(tab==R.id.tab_search){ - tabBar.selectTab(R.id.tab_search); - onTabSelected(R.id.tab_search); + } + if(tab==R.id.tab_search){ + if(currentTab!=R.id.tab_search){ + onTabSelected(R.id.tab_search); + tabBar.selectTab(R.id.tab_search); + } discoverFragment.openSearch(); return true; } + if(tab==R.id.tab_home){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args); + } return false; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 85a79b7edb..42aacbd6b9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -122,6 +122,10 @@ public void onCreate(Bundle savedInstanceState) { fragments=new Fragment[count]; tabViews=new FrameLayout[count]; timelines=new TimelineDefinition[count]; + if(GlobalUserPreferences.toolbarMarquee){ + setTitleMarqueeEnabled(false); + setSubtitleMarqueeEnabled(false); + } } @Override @@ -534,6 +538,12 @@ public boolean onOptionsItemSelected(MenuItem item){ @Override public void scrollToTop(){ + if (((IsOnTop) fragments[pager.getCurrentItem()]).isOnTop() && + GlobalUserPreferences.doubleTapToSwipe && !newPostsBtnShown) { + int nextPage = (pager.getCurrentItem() + 1) % count; + navigateTo(nextPage); + return; + } ((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop(); } @@ -607,8 +617,8 @@ public boolean isNewPostsBtnShown() { private void onNewPostsBtnClick(View view) { if(newPostsBtnShown){ - hideNewPostsButton(); scrollToTop(); + hideNewPostsButton(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java index 3d3781f368..fc805457a4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -2,6 +2,8 @@ import android.os.Bundle; import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; import android.widget.Toolbar; import org.joinmastodon.android.R; @@ -44,6 +46,7 @@ else if(wantsElevationOnScrollEffect()) list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect())); if(refreshLayout!=null) setRefreshLayoutColors(refreshLayout); + } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java index 6ce55442d8..0b56810937 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -1,9 +1,12 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.app.Dialog; import android.app.Fragment; import android.app.assist.AssistContent; +import android.content.Context; import android.content.res.Configuration; +import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -14,6 +17,7 @@ import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.LinearLayout; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; @@ -27,17 +31,28 @@ import org.joinmastodon.android.api.requests.accounts.GetFollowRequests; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.FollowRequestHandledEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.CheckIconSelectableTextView; +import org.joinmastodon.android.ui.views.CheckableLinearLayout; import org.joinmastodon.android.utils.ElevationOnScrollListener; import org.joinmastodon.android.utils.ObjectIdComparator; import org.joinmastodon.android.utils.ProvidesAssistContent; +import org.parceler.Parcels; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -46,7 +61,7 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; -public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener { +public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener, HasAccountID { TabLayout tabLayout; private ViewPager2 pager; @@ -54,7 +69,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc private View tabsDivider; private TabLayoutMediator tabLayoutMediator; String unreadMarker, realUnreadMarker; - private MenuItem markAllReadItem; + private MenuItem markAllReadItem, filterItem; private NotificationsListFragment allNotificationsFragment, mentionsFragment; private ElevationOnScrollListener elevationOnScrollListener; @@ -92,9 +107,10 @@ public void onShown() { public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.notifications, menu); menu.findItem(R.id.clear_notifications).setVisible(GlobalUserPreferences.enableDeleteNotifications); + filterItem=menu.findItem(R.id.filter_notifications).setVisible(true); markAllReadItem=menu.findItem(R.id.mark_all_read); updateMarkAllReadButton(); - UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read); + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read, R.id.filter_notifications); } @Override @@ -116,6 +132,62 @@ public boolean onOptionsItemSelected(MenuItem item) { if (getCurrentFragment() instanceof NotificationsListFragment nlf) { nlf.resetUnreadBackground(); } + return true; + } else if (item.getItemId() == R.id.filter_notifications) { + Context ctx = getToolbarContext(); + String[] listItems = { + ctx.getString(R.string.notification_type_mentions_and_replies), + ctx.getString(R.string.notification_type_reblog), + ctx.getString(R.string.notification_type_favorite), + ctx.getString(R.string.notification_type_follow), + ctx.getString(R.string.notification_type_poll), + ctx.getString(R.string.sk_notification_type_update), + ctx.getString(R.string.sk_notification_type_posts) + }; + + boolean[] checkedItems = { + getLocalPrefs().notificationFilters.mention, + getLocalPrefs().notificationFilters.reblog, + getLocalPrefs().notificationFilters.favourite, + getLocalPrefs().notificationFilters.follow, + getLocalPrefs().notificationFilters.poll, + getLocalPrefs().notificationFilters.update, + getLocalPrefs().notificationFilters.status, + }; + + M3AlertDialogBuilder dialogBuilder = new M3AlertDialogBuilder(ctx); + dialogBuilder.setTitle(R.string.sk_settings_filters); + dialogBuilder.setMultiChoiceItems(listItems, checkedItems, (dialog, which, isChecked) -> { + checkedItems[which] = isChecked; + }); + + dialogBuilder.setPositiveButton(R.string.save, (d, which) -> { + getLocalPrefs().notificationFilters.mention=checkedItems[0]; + getLocalPrefs().notificationFilters.reblog=checkedItems[1]; + getLocalPrefs().notificationFilters.favourite=checkedItems[2]; + getLocalPrefs().notificationFilters.follow=checkedItems[3]; + getLocalPrefs().notificationFilters.poll=checkedItems[4]; + getLocalPrefs().notificationFilters.update=checkedItems[5]; + getLocalPrefs().notificationFilters.status=checkedItems[6]; + getLocalPrefs().save(); + + this.allNotificationsFragment.reload(); + }).setNeutralButton(R.string.clear, (d, which) -> { + Arrays.fill(checkedItems, true); + getLocalPrefs().notificationFilters.mention=checkedItems[0]; + getLocalPrefs().notificationFilters.reblog=checkedItems[1]; + getLocalPrefs().notificationFilters.favourite=checkedItems[2]; + getLocalPrefs().notificationFilters.follow=checkedItems[3]; + getLocalPrefs().notificationFilters.poll=checkedItems[4]; + getLocalPrefs().notificationFilters.update=checkedItems[5]; + getLocalPrefs().notificationFilters.status=checkedItems[6]; + getLocalPrefs().save(); + + this.allNotificationsFragment.reload(); + }).setNegativeButton(R.string.cancel, (d, which) -> {}); + + dialogBuilder.create().show(); + return true; } return false; @@ -184,6 +256,7 @@ public void onTabReselected(TabLayout.Tab tab) { public void onPageSelected(int position){ if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) elevationOnScrollListener.handleScroll(getContext(), f.isOnTop()); + filterItem.setVisible(position==0); if(position==0) return; Fragment _page=getFragmentForPage(position); @@ -270,9 +343,15 @@ public void onFollowRequestHandled(FollowRequestHandledEvent ev) { @Override public void scrollToTop(){ + if (getFragmentForPage(pager.getCurrentItem()).isOnTop() && GlobalUserPreferences.doubleTapToSwipe) { + int nextPage = (pager.getCurrentItem() + 1) % tabViews.length; + pager.setCurrentItem(nextPage, true); + return; + } getFragmentForPage(pager.getCurrentItem()).scrollToTop(); } + public void loadData(){ refreshFollowRequestsBadge(); if(allNotificationsFragment!=null && !allNotificationsFragment.loaded && !allNotificationsFragment.dataLoading) @@ -303,6 +382,11 @@ public void onProvideAssistContent(AssistContent assistContent) { callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); } + @Override + public String getAccountID(){ + return accountID; + } + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index bf07bbbbd2..3bd8589384 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -38,6 +38,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -87,13 +88,47 @@ public void onAttach(Activity activity){ @Override protected List buildDisplayItems(Notification n){ + if(!onlyMentions && !onlyPosts){ + switch(n.type){ + case MENTION -> { + if(!getLocalPrefs().notificationFilters.mention) + return new ArrayList<>(); + } + case REBLOG -> { + if(!getLocalPrefs().notificationFilters.reblog) + return new ArrayList<>(); + } + case FAVORITE, REACTION -> { + if(!getLocalPrefs().notificationFilters.favourite) + return new ArrayList<>(); + } + case FOLLOW, FOLLOW_REQUEST -> { + if(!getLocalPrefs().notificationFilters.follow) + return new ArrayList<>(); + } + case POLL -> { + if(!getLocalPrefs().notificationFilters.poll) + return new ArrayList<>(); + } + case UPDATE -> { + if(!getLocalPrefs().notificationFilters.update) + return new ArrayList<>(); + } + case STATUS -> { + if(!getLocalPrefs().notificationFilters.status) + return new ArrayList<>(); + } + default -> {} + } + } + NotificationHeaderStatusDisplayItem titleItem; if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ titleItem=null; }else{ titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); } - if (n.type == Notification.Type.FOLLOW_REQUEST) { + if (n.type == Notification.Type.FOLLOW_REQUEST || n.type == Notification.Type.FOLLOW) { ArrayList items = new ArrayList<>(); items.add(titleItem); items.add(new AccountCardStatusDisplayItem(n.id, this, accountID, n.account, n)); @@ -103,6 +138,8 @@ protected List buildDisplayItems(Notification n){ int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS); // | StatusDisplayItem.FLAG_NO_HEADER); if (GlobalUserPreferences.spectatorMode) flags |= StatusDisplayItem.FLAG_NO_FOOTER; + if (!GlobalUserPreferences.showMediaPreview) + flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW; ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags); if(titleItem!=null) items.add(0, titleItem); @@ -132,6 +169,13 @@ protected void doLoadData(int offset, int count){ public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) return; + + Set needRelationships=result.items.stream() + .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) + .map(ntf->ntf.account.id) + .collect(Collectors.toSet()); + loadRelationships(needRelationships); + maxID=result.maxID; onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); if(bannerHelper!=null) bannerHelper.onBannerBecameVisible(); @@ -142,6 +186,17 @@ public void onSuccess(PaginatedResponse> result){ } }); } + + @Override + protected void onRelationshipsLoaded(){ + if(getActivity()==null) + return; + for(int i=0;i fields=Collections.emptyList(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index fd30387ba1..9a1d9b2d17 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -23,6 +23,7 @@ import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.text.style.ImageSpan; import android.transition.ChangeBounds; import android.transition.Fade; import android.transition.TransitionManager; @@ -39,6 +40,8 @@ import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.inputmethod.InputMethodManager; +import android.view.animation.TranslateAnimation; +import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageButton; @@ -49,6 +52,11 @@ import android.widget.Toast; import android.widget.Toolbar; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.viewpager2.widget.ViewPager2; + import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; @@ -56,13 +64,14 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; +import org.joinmastodon.android.api.requests.accounts.SetPrivateNote; import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.fragments.account_list.BlockedAccountsListFragment; +import org.joinmastodon.android.fragments.account_list.BlocksListFragment; import org.joinmastodon.android.fragments.account_list.FollowerListFragment; import org.joinmastodon.android.fragments.account_list.FollowingListFragment; -import org.joinmastodon.android.fragments.account_list.MutedAccountsListFragment; +import org.joinmastodon.android.fragments.account_list.MutesListFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.fragments.settings.SettingsServerFragment; import org.joinmastodon.android.model.Account; @@ -99,13 +108,9 @@ import java.util.Collections; import java.util.List; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import androidx.viewpager2.widget.ViewPager2; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -133,7 +138,6 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; private View avatarBorder; - private View usernameWrap; private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; private ImageView lockIcon, botIcon; private ProgressBarButton actionButton, notifyButton; @@ -151,11 +155,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private TextView followsYouView; private ViewGroup rolesView; private LinearLayout countersLayout; - private View nameEditWrap, bioEditWrap; + private View nameEditWrap, bioEditWrap, usernameWrap; private View tabsDivider; private View actionButtonWrap; private CustomDrawingOrderLinearLayout scrollableContent; + public FrameLayout noteWrap; + public EditText noteEdit; + private String note; private Account account, remoteAccount; private String accountID; private String domain; @@ -255,6 +262,7 @@ public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bu bioEdit=content.findViewById(R.id.bio_edit); nameEditWrap=content.findViewById(R.id.name_edit_wrap); bioEditWrap=content.findViewById(R.id.bio_edit_wrap); + usernameWrap=content.findViewById(R.id.username_wrap); actionProgress=content.findViewById(R.id.action_progress); notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); @@ -271,6 +279,61 @@ public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bu avatar.setOutlineProvider(OutlineProviders.roundedRect(24)); avatar.setClipToOutline(true); + noteEdit = content.findViewById(R.id.note_edit); + noteWrap = content.findViewById(R.id.note_edit_wrap); + ImageButton noteEditConfirm = content.findViewById(R.id.note_edit_confirm); + + noteEditConfirm.setOnClickListener((v -> { + if (!noteEdit.getText().toString().trim().equals(note)) { + savePrivateNote(); + } + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Activity.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(this.getView().getRootView().getWindowToken(), 0); + noteEdit.clearFocus(); + })); + + + noteEdit.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + fab.setVisibility(View.INVISIBLE); + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + 0, + fab.getHeight() * 2); + animate.setDuration(300); + fab.startAnimation(animate); + + noteEditConfirm.setVisibility(View.VISIBLE); + noteEditConfirm.animate() + .alpha(1.0f) + .setDuration(700); + } else { + fab.setVisibility(View.VISIBLE); + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + fab.getHeight() * 2, + 0); + animate.setDuration(300); + fab.startAnimation(animate); + + noteEditConfirm.animate() + .alpha(0.0f) + .setDuration(700); + noteEditConfirm.setVisibility(View.INVISIBLE); + } + }); + + noteEditConfirm.setOnClickListener((v -> { + if (!noteEdit.getText().toString().trim().equals(note)) { + savePrivateNote(); + } + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Activity.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(this.getView().getRootView().getWindowToken(), 0); + noteEdit.clearFocus(); + })); + FrameLayout sizeWrapper=new FrameLayout(getActivity()){ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ @@ -461,6 +524,25 @@ private void onAccountLoaded(Account result) { V.setVisibilityAnimated(fab, View.VISIBLE); } + public void setNote(String note){ + this.note=note; + noteWrap.setVisibility(View.VISIBLE); + noteEdit.setVisibility(View.VISIBLE); + noteEdit.setText(note); + } + + private void savePrivateNote(){ + new SetPrivateNote(profileAccountID, noteEdit.getText().toString()).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship result) {} + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(accountID); + } + @Override protected void doLoadData(){ if (remoteAccount != null) { @@ -491,6 +573,11 @@ public void onSuccess(Account result){ @Override public void onRefresh(){ + if(isInEditMode){ + refreshing=false; + refreshLayout.setRefreshing(false); + return; + } if(refreshing) return; refreshing=true; @@ -751,7 +838,11 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(relationship==null && !isOwnProfile) return; inflater.inflate(isOwnProfile ? R.menu.profile_own : R.menu.profile, menu); - UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.bookmarks, R.id.followed_hashtags); + if(isOwnProfile){ + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.scheduled, R.id.bookmarks, R.id.favorites); + }else{ + UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.bookmarks, R.id.followed_hashtags, R.id.favorites, R.id.scheduled); + } boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; MenuItem openWithAccounts = menu.findItem(R.id.open_with_account); openWithAccounts.setVisible(hasMultipleAccounts); @@ -771,7 +862,7 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ menu.findItem(R.id.manage_user_lists).setTitle(getString(R.string.sk_lists_with_user, account.getShortUsername())); MenuItem mute=menu.findItem(R.id.mute); mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); - mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); + mute.setIcon(relationship.muting ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); UiUtils.insetPopupMenuIcon(getContext(), mute); menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername())); menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getShortUsername())); @@ -857,12 +948,12 @@ public void onError(ErrorResponse error){ final Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("targetAccount", Parcels.wrap(account)); - Nav.go(getActivity(), MutedAccountsListFragment.class, args); + Nav.go(getActivity(), MutesListFragment.class, args); }else if(id==R.id.blocked_accounts){ final Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelable("targetAccount", Parcels.wrap(account)); - Nav.go(getActivity(), BlockedAccountsListFragment.class, args); + Nav.go(getActivity(), BlocksListFragment.class, args); }else if(id==R.id.followed_hashtags){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -908,6 +999,11 @@ private void updateRelationship(){ followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE); notifyButton.setSelected(relationship.notifying); notifyButton.setContentDescription(getString(relationship.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username)); + + if (!isOwnProfile) { + setNote(relationship.note); +// aboutFragment.setNote(relationship.note, accountID, profileAccountID); + } } public ImageButton getFab() { @@ -1218,6 +1314,9 @@ private void updateRelationship(Relationship r){ @Override public boolean onBackPressed(){ + if(noteEdit.hasFocus()) { + savePrivateNote(); + } if(isInEditMode){ if(savingEdits) return true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java index 9140f57d23..a8001f59b2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -17,6 +17,7 @@ import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses; import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; @@ -203,10 +204,7 @@ protected ScheduledStatus getStatusByID(String id){ public void onApplyWindowInsets(WindowInsets insets){ if(contentView!=null){ if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ - int insetBottom=insets.getSystemWindowInsetBottom(); - ((ViewGroup.MarginLayoutParams) list.getLayoutParams()).bottomMargin=insetBottom; - ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insetBottom; - insets=insets.inset(0, 0, 0, insetBottom); + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insets.getSystemWindowInsetBottom(); }else{ ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java index af675748a9..fb10263dc9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScrollableToTop.java @@ -7,6 +7,8 @@ import org.joinmastodon.android.ui.utils.UiUtils; public interface ScrollableToTop{ +// boolean isScrolledToTop(); + void scrollToTop(); /** diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java index 0a723a3220..1f5fdff9fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java @@ -16,14 +16,17 @@ import org.joinmastodon.android.ui.utils.UiUtils; import java.time.ZoneId; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.EnumSet; +import java.util.LinkedList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; +import name.fraser.neil.plaintext.diff_match_patch; public class StatusEditHistoryFragment extends StatusListFragment{ private String id, url; @@ -58,7 +61,7 @@ public void onSuccess(List result){ @Override protected List buildDisplayItems(Status s){ - List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS); + List items=new ArrayList<>(); int idx=data.indexOf(s); if(idx>=0){ String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault())); @@ -85,6 +88,8 @@ enum StatusEditChangeType{ if(!Objects.equals(s.content, prev.content)){ changes.add(StatusEditChangeType.TEXT_CHANGED); + //update status content to display a diffs + s.content=createDiffText(prev.content, s.content); } if(!Objects.equals(s.spoilerText, prev.spoilerText)){ if(s.spoilerText==null){ @@ -147,6 +152,7 @@ enum StatusEditChangeType{ items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" "+sep+" "+date, Collections.emptyList(), 0, null, null, s)); items.add(1, new DummyStatusDisplayItem(s.id, this)); } + items.addAll(StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_FOOTER|StatusDisplayItem.FLAG_INSET|StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS)); return items; } @@ -170,4 +176,28 @@ protected FilterContext getFilterContext() { public Uri getWebUri(Uri.Builder base) { return Uri.parse(url); } + + private String createDiffText(String original, String modified) { + diff_match_patch dmp = new diff_match_patch(); + LinkedList diffs = dmp.diff_main(original, modified); + dmp.diff_cleanupSemantic(diffs); + + StringBuilder stringBuilder = new StringBuilder(); + for(diff_match_patch.Diff diff: diffs){ + switch(diff.operation){ + case DELETE -> { + stringBuilder.append(""); + stringBuilder.append(diff.text); + stringBuilder.append(""); + } + case INSERT -> { + stringBuilder.append(""); + stringBuilder.append(diff.text); + stringBuilder.append(""); + } + default -> stringBuilder.append(diff.text); + } + } + return stringBuilder.toString(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index 058aea1ea5..47ebee9ad8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -12,6 +12,7 @@ import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.ReblogDeletedEvent; @@ -25,9 +26,11 @@ import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.util.ArrayList; @@ -56,6 +59,8 @@ protected List buildDisplayItems(Status s){ flags |= StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS; if(GlobalUserPreferences.translateButtonOpenedOnly) flags |= StatusDisplayItem.FLAG_NO_TRANSLATE; + if(!GlobalUserPreferences.showMediaPreview) + flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW; return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), isMainThreadStatus ? 0 : flags); } @@ -86,6 +91,18 @@ public void onItemClick(String id){ Status status=getContentStatusByID(id); if(status==null) return; + if(status.isRemote){ + UiUtils.lookupStatus(getContext(), status, accountID, null, status1 -> { + status1.filterRevealed = true; + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(status1)); + if(status1.inReplyToAccountId!=null && knownAccounts.containsKey(status1.inReplyToAccountId)) + args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status1.inReplyToAccountId))); + Nav.go(getActivity(), ThreadFragment.class, args); + }); + return; + } status.filterRevealed=true; Bundle args=new Bundle(); args.putString("account", accountID); @@ -283,6 +300,28 @@ public void onStatusCountersUpdated(StatusCountersUpdatedEvent ev){ } } + @Subscribe + public void onStatusMuteChaged(StatusMuteChangedEvent ev){ + for(Status s:data){ + if(s.getContentStatus().id.equals(ev.id)){ + s.getContentStatus().update(ev); + AccountSessionManager.get(accountID).getCacheController().updateStatus(s); + for(int i=0;i getDescendantsOrdered(String id, List status private static List getDirectDescendants(String id, List statuses){ return statuses.stream() - .filter(s -> s.inReplyToId.equals(id)) + .filter(s -> id.equals(s.inReplyToId)) .collect(Collectors.toList()); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BlocksListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BlocksListFragment.java new file mode 100644 index 0000000000..f13f6e3597 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BlocksListFragment.java @@ -0,0 +1,36 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.net.Uri; +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountBlocks; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +public class BlocksListFragment extends AccountRelatedAccountListFragment{ + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.mo_blocked_accounts); + } + + @Override + public HeaderPaginationRequest onCreateRequest(String maxID, int count){ + return new GetAccountBlocks(maxID, count); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath("/blocks").build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java new file mode 100644 index 0000000000..64acdaef24 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/MutesListFragment.java @@ -0,0 +1,36 @@ +package org.joinmastodon.android.fragments.account_list; + +import android.net.Uri; +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountMutes; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.viewholders.AccountViewHolder; + +public class MutesListFragment extends AccountRelatedAccountListFragment{ + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.mo_muted_accounts); + } + + @Override + public HeaderPaginationRequest onCreateRequest(String maxID, int count){ + return new GetAccountMutes(maxID, count); + } + + @Override + protected void onConfigureViewHolder(AccountViewHolder holder){ + super.onConfigureViewHolder(holder); + holder.setStyle(AccountViewHolder.AccessoryType.NONE, false); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath("/mutes").build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java index d7194bbf37..8c0192343c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java @@ -311,6 +311,8 @@ private void onActionButtonClick(View v){ private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); actionButton.setClickable(!visible); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java index 366e8fe5c4..b09cadc343 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java @@ -12,6 +12,7 @@ import android.widget.LinearLayout; import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.IsOnTop; @@ -208,6 +209,11 @@ public void openSearch() { @Override public void scrollToTop(){ if(!searchActive){ + if (((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop() && GlobalUserPreferences.doubleTapToSwipe){ + int nextPage=(pager.getCurrentItem()+1)%tabViews.length; + pager.setCurrentItem(nextPage, true); + return; + } ((ScrollableToTop)getFragmentForPage(pager.getCurrentItem())).scrollToTop(); }else{ searchFragment.scrollToTop(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java index e0814fa452..b4a8d0950e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java @@ -11,6 +11,7 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.trends.GetTrendingLinks; +import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Card; import org.joinmastodon.android.model.viewmodel.CardViewModel; @@ -39,7 +40,7 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class DiscoverNewsFragment extends BaseRecyclerFragment implements ScrollableToTop{ +public class DiscoverNewsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop{ private String accountID; private DiscoverInfoBannerHelper bannerHelper; private MergeRecyclerAdapter mergeAdapter; @@ -109,6 +110,11 @@ public void scrollToTop(){ smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + private class LinksAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ private final List data; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java index 9995158781..fb25819ed6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java @@ -26,6 +26,7 @@ public void onCreate(Bundle savedInstanceState){ bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS, accountID); } + @Override protected void doLoadData(int o, int count){ if(refreshing) offset=0; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java index 84bceb46fc..d3876d66b6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java @@ -127,7 +127,11 @@ protected void doLoadData(int offset, int count){ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(SearchResults result){ - onDataLoaded(Stream.of(result.hashtags.stream().map(SearchResult::new), result.accounts.stream().map(SearchResult::new)) + onDataLoaded(Stream + .of( + result.hashtags.stream().filter(hashtag -> !hashtag.name.isEmpty()).map(SearchResult::new), + result.accounts.stream().map(SearchResult::new) + ) .flatMap(Function.identity()) .map(sr->{ SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false); @@ -477,6 +481,8 @@ private void onClearRecentClick(){ } private void deliverResult(String query, SearchResult.Type typeFilter){ + if(query.isEmpty()) + return; Bundle res=new Bundle(); res.putString("query", query); if(typeFilter!=null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java index 67a3f6eac1..9ec45d5f14 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java @@ -8,6 +8,7 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags; +import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.ui.utils.UiUtils; @@ -22,7 +23,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class TrendingHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop{ +public class TrendingHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop{ private String accountID; public TrendingHashtagsFragment(){ @@ -59,6 +60,11 @@ public void scrollToTop(){ smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop(){ + return isRecyclerViewOnTop(list); + } + private class HashtagsAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java index 6f2065adc1..54f35591f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java @@ -142,8 +142,8 @@ protected RecyclerView.Adapter getAdapter(){ headerView.findViewById(R.id.unread_indicator).setVisibility(View.GONE); headerView.findViewById(R.id.separator).setVisibility(View.GONE); headerView.findViewById(R.id.time).setVisibility(View.GONE); - ((TextView) headerView.findViewById(R.id.username)).setText(R.string.sk_app_username); - ((TextView) headerView.findViewById(R.id.name)).setText(R.string.sk_app_name); + ((TextView) headerView.findViewById(R.id.username)).setText(R.string.mo_app_username); + ((TextView) headerView.findViewById(R.id.name)).setText(R.string.mo_app_name); ((ImageView) headerView.findViewById(R.id.avatar)).setImageDrawable(getActivity().getDrawable(R.mipmap.ic_launcher)); ((FragmentStackActivity) getActivity()).invalidateSystemBarColors(this); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java index e4ce64016e..f71f3cfd3c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceChooserLoginFragment.java @@ -106,13 +106,13 @@ public void onError(ErrorResponse error){ .execNoAuth(""); } - @Override - protected void onUpdateToolbar(){ - super.onUpdateToolbar(); - Toolbar toolbar=getToolbar(); - toolbar.setElevation(0); - toolbar.setBackground(null); - } +// @Override +// protected void onUpdateToolbar(){ +// super.onUpdateToolbar(); +// Toolbar toolbar=getToolbar(); +// toolbar.setElevation(0); +// toolbar.setBackground(null); +// } @Override protected RecyclerView.Adapter getAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index d9ee9c773d..bbf7eac0c1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -127,7 +127,7 @@ public void onApplyWindowInsets(WindowInsets insets){ if(Build.VERSION.SDK_INT>=27){ int inset=insets.getSystemWindowInsetBottom(); buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); - super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); }else{ super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java index 374aeef9f0..a59d97749d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -53,7 +53,7 @@ public void onViewCreated(View view, Bundle savedInstanceState){ list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); view.findViewById(R.id.btn_next).setOnClickListener(UiUtils.rateLimitedClickListener(this::onFollowAllClick)); - view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); +// view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); } @Override @@ -147,9 +147,9 @@ public void onError(ErrorResponse error){ } private void proceed(){ - Bundle args=new Bundle(); - args.putString("account", accountID); - Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args); +// Bundle args=new Bundle(); +// args.putString("account", accountID); +// Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java index bee767c193..6aa1edea0d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java @@ -68,6 +68,10 @@ public class EditFilterFragment extends BaseSettingsFragment implements On public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); filter=Parcels.unwrap(getArguments().getParcelable("filter")); + ArrayList words=getArguments().getParcelableArrayList("words"); + if (words != null) { + words.stream().map(p->(FilterKeyword)Parcels.unwrap(p)).forEach(keywords::add); + } setTitle(filter==null ? R.string.settings_add_filter : R.string.settings_edit_filter); onDataLoaded(List.of( durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick), diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java index 7d80f34d8d..18e9a20805 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -12,9 +12,12 @@ import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.HasAccountID; import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.utils.UiUtils; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import androidx.recyclerview.widget.RecyclerView; @@ -23,21 +26,24 @@ import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; -public class SettingsAboutAppFragment extends BaseSettingsFragment{ +public class SettingsAboutAppFragment extends BaseSettingsFragment implements HasAccountID{ private ListItem mediaCacheItem; private AccountSession session; private boolean timelineCacheCleared=false; + // MOSHIDON + private ListItem clearRecentEmojisItem; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setTitle(getString(R.string.about_app, getString(R.string.sk_app_name))); + setTitle(getString(R.string.about_app, getString(R.string.mo_app_name))); session=AccountSessionManager.get(accountID); onDataLoaded(List.of( - new ListItem<>(R.string.sk_settings_donate, 0, R.drawable.ic_fluent_heart_24_regular, i->UiUtils.openHashtagTimeline(getActivity(), accountID, getString(R.string.donate_hashtag))), - new ListItem<>(R.string.sk_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.repo_url))), + new ListItem<>(R.string.sk_settings_donate, 0, R.drawable.ic_fluent_heart_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_donate_url))), + new ListItem<>(R.string.mo_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_repo_url))), new ListItem<>(R.string.settings_tos, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")), new ListItem<>(R.string.settings_privacy_policy, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true), + clearRecentEmojisItem=new ListItem<>(R.string.mo_clear_recent_emoji, 0, this::onClearRecentEmojisClick), mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick), new ListItem<>(getString(R.string.sk_settings_clear_timeline_cache), session.domain, this::onClearTimelineCacheClick) )); @@ -60,12 +66,11 @@ protected RecyclerView.Adapter getAdapter(){ adapter.addAdapter(super.getAdapter()); TextView versionInfo=new TextView(getActivity()); - versionInfo.setSingleLine(); versionInfo.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(32))); versionInfo.setTextAppearance(R.style.m3_label_medium); versionInfo.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline)); versionInfo.setGravity(Gravity.CENTER); - versionInfo.setText(getString(R.string.sk_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); + versionInfo.setText(getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); adapter.addAdapter(new SingleViewRecyclerAdapter(versionInfo)); return adapter; @@ -88,10 +93,21 @@ private void onClearTimelineCacheClick(ListItem item){ timelineCacheCleared=true; } + private void onClearRecentEmojisClick(ListItem item){ + getLocalPrefs().recentCustomEmoji=new ArrayList<>(); + getLocalPrefs().save(); + Toast.makeText(getContext(), R.string.mo_recent_emoji_cleared, Toast.LENGTH_SHORT).show(); + } + private void updateMediaCacheItem(){ long size=ImageCache.getInstance(getActivity()).getDiskCache().size(); mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false); mediaCacheItem.isEnabled=size>0; rebindItem(mediaCacheItem); } + + @Override + public String getAccountID(){ + return accountID; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java index 62c32d8fb3..6e915b00d2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java @@ -33,6 +33,9 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment impleme private ListItem prefixRepliesItem, replyVisibilityItem; private CheckableListItem forwardReportsItem, remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem; + // MOSHIDON + private CheckableListItem mentionRebloggerAutomaticallyItem, hapticFeedbackItem, unlistedRepliesItem, showPostsWithoutAltItem; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -47,7 +50,9 @@ public void onCreate(Bundle savedInstanceState){ List> items = new ArrayList<>(List.of( languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(getContext()) : null, R.drawable.ic_fluent_local_language_24_regular, this::onDefaultLanguageClick), altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_fluent_image_alt_text_24_regular, i->toggleCheckableItem(altTextItem)), - playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_fluent_gif_24_regular, i->toggleCheckableItem(playGifsItem)), + showPostsWithoutAltItem=new CheckableListItem<>(R.string.mo_settings_show_posts_without_alt, R.string.mo_settings_show_posts_without_alt_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showPostsWithoutAlt, R.drawable.ic_fluent_eye_tracking_on_24_regular, i->toggleCheckableItem(showPostsWithoutAltItem)), + playGifsItem=new CheckableListItem<>(R.string.settings_gif, R.string.mo_setting_play_gif_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_fluent_gif_24_regular, i->toggleCheckableItem(playGifsItem)), + unlistedRepliesItem=new CheckableListItem<>(R.string.mo_change_default_reply_visibility_to_unlisted, R.string.mo_setting_default_reply_privacy_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.defaultToUnlistedReplies, R.drawable.ic_fluent_lock_open_24_regular, i->toggleCheckableItem(unlistedRepliesItem)), overlayMediaItem=new CheckableListItem<>(R.string.sk_settings_continues_playback, R.string.sk_settings_continues_playback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.overlayMedia, R.drawable.ic_fluent_play_circle_hint_24_regular, i->toggleCheckableItem(overlayMediaItem)), customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_fluent_link_24_regular, i->toggleCheckableItem(customTabsItem)), confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_fluent_person_delete_24_regular, i->toggleCheckableItem(confirmUnfollowItem)), @@ -57,7 +62,9 @@ public void onCreate(Bundle savedInstanceState){ forwardReportsItem=new CheckableListItem<>(R.string.sk_settings_forward_report_default, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.forwardReportDefault, R.drawable.ic_fluent_arrow_forward_24_regular, i->toggleCheckableItem(forwardReportsItem)), loadNewPostsItem=new CheckableListItem<>(R.string.sk_settings_load_new_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.loadNewPosts, R.drawable.ic_fluent_arrow_sync_24_regular, i->onLoadNewPostsClick()), seeNewPostsBtnItem=new CheckableListItem<>(R.string.sk_settings_see_new_posts_button, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNewPostsButton, R.drawable.ic_fluent_arrow_up_24_regular, i->toggleCheckableItem(seeNewPostsBtnItem)), - remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem), true), + remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem)), + mentionRebloggerAutomaticallyItem=new CheckableListItem<>(R.string.mo_mention_reblogger_automatically, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.mentionRebloggerAutomatically, R.drawable.ic_fluent_comment_mention_24_regular, i->toggleCheckableItem(mentionRebloggerAutomaticallyItem)), + hapticFeedbackItem=new CheckableListItem<>(R.string.mo_haptic_feedback, R.string.mo_setting_haptic_feedback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.hapticFeedback, R.drawable.ic_fluent_phone_vibrate_24_regular, i->toggleCheckableItem(hapticFeedbackItem), true), showBoostsItem=new CheckableListItem<>(R.string.sk_settings_show_boosts, 0, CheckableListItem.Style.SWITCH, lp.showBoosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(showBoostsItem)), showRepliesItem=new CheckableListItem<>(R.string.sk_settings_show_replies, 0, CheckableListItem.Style.SWITCH, lp.showReplies, R.drawable.ic_fluent_arrow_reply_24_regular, i->toggleCheckableItem(showRepliesItem)) )); @@ -174,6 +181,10 @@ protected void onHidden(){ GlobalUserPreferences.loadNewPosts=loadNewPostsItem.checked; GlobalUserPreferences.showNewPostsButton=seeNewPostsBtnItem.checked; GlobalUserPreferences.allowRemoteLoading=remoteLoadingItem.checked; + GlobalUserPreferences.mentionRebloggerAutomatically=mentionRebloggerAutomaticallyItem.checked; + GlobalUserPreferences.hapticFeedback=hapticFeedbackItem.checked; + GlobalUserPreferences.defaultToUnlistedReplies=unlistedRepliesItem.checked; + GlobalUserPreferences.showPostsWithoutAlt=showPostsWithoutAltItem.checked; GlobalUserPreferences.save(); AccountLocalPreferences lp=getLocalPrefs(); boolean restartPlease=lp.showBoosts!=showBoostsItem.checked diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java index dcfde4ae50..9e675ee8c0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java @@ -6,6 +6,7 @@ import android.graphics.Canvas; import android.os.Build; import android.os.Bundle; +import android.provider.Settings; import android.text.TextUtils; import android.view.View; import android.view.WindowManager; @@ -45,6 +46,9 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ private ListItem colorItem, publishTextItem, autoRevealCWsItem; private CheckableListItem pronounsInUserListingsItem, pronounsInTimelinesItem, pronounsInThreadsItem; + // MOSHIDON + private CheckableListItem enableDoubleTapToSwipeItem, relocatePublishButtonItem, showPostDividersItem, enableDoubleTapToSearchItem, showMediaPreviewItem; + private AccountLocalPreferences lp; @Override @@ -56,16 +60,20 @@ public void onCreate(Bundle savedInstanceState){ onDataLoaded(List.of( themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_fluent_weather_moon_24_regular, this::onAppearanceClick), colorItem=new ListItem<>(getString(R.string.sk_settings_color_palette), getColorPaletteValue(), R.drawable.ic_fluent_color_24_regular, this::onColorClick), - trueBlackModeItem=new CheckableListItem<>(R.string.sk_settings_true_black, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.trueBlackTheme, R.drawable.ic_fluent_dark_theme_24_regular, i->onTrueBlackModeClick(), true), + trueBlackModeItem=new CheckableListItem<>(R.string.sk_settings_true_black, R.string.mo_setting_true_black_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.trueBlackTheme, R.drawable.ic_fluent_dark_theme_24_regular, i->onTrueBlackModeClick(), true), publishTextItem=new ListItem<>(getString(R.string.sk_settings_publish_button_text), getPublishButtonText(), R.drawable.ic_fluent_send_24_regular, this::onPublishTextClick), autoRevealCWsItem=new ListItem<>(R.string.sk_settings_auto_reveal_equal_spoilers, getAutoRevealSpoilersText(), R.drawable.ic_fluent_eye_24_regular, this::onAutoRevealSpoilersClick), + relocatePublishButtonItem=new CheckableListItem<>(R.string.mo_relocate_publish_button, R.string.mo_setting_relocate_publish_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.relocatePublishButton, R.drawable.ic_fluent_arrow_autofit_down_24_regular, i->toggleCheckableItem(relocatePublishButtonItem)), revealCWsItem=new CheckableListItem<>(R.string.sk_settings_always_reveal_content_warnings, 0, CheckableListItem.Style.SWITCH, lp.revealCWs, R.drawable.ic_fluent_chat_warning_24_regular, i->toggleCheckableItem(revealCWsItem)), hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_fluent_flag_24_regular, i->toggleCheckableItem(hideSensitiveMediaItem)), - interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_fluent_number_row_24_regular, i->toggleCheckableItem(interactionCountsItem)), + showMediaPreviewItem=new CheckableListItem<>(R.string.mo_show_media_preview, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showMediaPreview, R.drawable.ic_fluent_image_24_regular, i->toggleCheckableItem(showMediaPreviewItem)), + interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, R.string.mo_setting_interaction_count_summary, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_fluent_number_row_24_regular, i->toggleCheckableItem(interactionCountsItem)), emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_fluent_emoji_24_regular, i->toggleCheckableItem(emojiInNamesItem)), - marqueeItem=new CheckableListItem<>(R.string.sk_settings_enable_marquee, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.toolbarMarquee, R.drawable.ic_fluent_text_more_24_regular, i->toggleCheckableItem(marqueeItem)), - reduceMotionItem=new CheckableListItem<>(R.string.sk_settings_reduce_motion, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.reduceMotion, R.drawable.ic_fluent_star_emphasis_24_regular, i->toggleCheckableItem(reduceMotionItem)), - disableSwipeItem=new CheckableListItem<>(R.string.sk_settings_tabs_disable_swipe, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableSwipe, R.drawable.ic_fluent_swipe_right_24_regular, i->toggleCheckableItem(disableSwipeItem)), + marqueeItem=new CheckableListItem<>(R.string.sk_settings_enable_marquee, R.string.mo_setting_marquee_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.toolbarMarquee, R.drawable.ic_fluent_text_more_24_regular, i->toggleCheckableItem(marqueeItem)), + reduceMotionItem=new CheckableListItem<>(R.string.sk_settings_reduce_motion, R.string.mo_setting_reduced_motion_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.reduceMotion, R.drawable.ic_fluent_star_emphasis_24_regular, i->toggleCheckableItem(reduceMotionItem)), + enableDoubleTapToSearchItem=new CheckableListItem<>(R.string.mo_double_tap_to_search, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSearch, R.drawable.ic_fluent_search_24_regular, i->toggleCheckableItem(enableDoubleTapToSearchItem)), + disableSwipeItem=new CheckableListItem<>(R.string.sk_settings_tabs_disable_swipe, R.string.mo_setting_disable_swipe_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableSwipe, R.drawable.ic_fluent_swipe_right_24_regular, i->toggleCheckableItem(disableSwipeItem)), + enableDoubleTapToSwipeItem=new CheckableListItem<>(R.string.mo_double_tap_to_swipe_between_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSwipe, R.drawable.ic_fluent_double_tap_swipe_right_24_regular, i->toggleCheckableItem(enableDoubleTapToSwipeItem)), altIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showAltIndicator, R.drawable.ic_fluent_scan_text_24_regular, i->toggleCheckableItem(altIndicatorItem)), noAltIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_no_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNoAltIndicator, R.drawable.ic_fluent_important_24_regular, i->toggleCheckableItem(noAltIndicatorItem)), collapsePostsItem=new CheckableListItem<>(R.string.sk_settings_collapse_long_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.collapseLongPosts, R.drawable.ic_fluent_chevron_down_24_regular, i->toggleCheckableItem(collapsePostsItem)), @@ -74,6 +82,7 @@ public void onCreate(Bundle savedInstanceState){ translateOpenedItem=new CheckableListItem<>(R.string.sk_settings_translate_only_opened, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.translateButtonOpenedOnly, R.drawable.ic_fluent_translate_24_regular, i->toggleCheckableItem(translateOpenedItem)), likeIconItem=new CheckableListItem<>(R.string.sk_settings_like_icon, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.likeIcon, R.drawable.ic_fluent_heart_24_regular, i->toggleCheckableItem(likeIconItem)), underlinedLinksItem=new CheckableListItem<>(R.string.sk_settings_underlined_links, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.underlinedLinks, R.drawable.ic_fluent_text_underline_24_regular, i->toggleCheckableItem(underlinedLinksItem)), + showPostDividersItem=new CheckableListItem<>(R.string.mo_enable_dividers, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showDividers, R.drawable.ic_fluent_timeline_24_regular, i->toggleCheckableItem(showPostDividersItem)), disablePillItem=new CheckableListItem<>(R.string.sk_disable_pill_shaped_active_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableM3PillActiveIndicator, R.drawable.ic_fluent_pill_24_regular, i->toggleCheckableItem(disablePillItem)), showNavigationLabelsItem=new CheckableListItem<>(R.string.sk_settings_show_labels_in_navigation_bar, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNavigationLabels, R.drawable.ic_fluent_tag_24_regular, i->toggleCheckableItem(showNavigationLabelsItem), true), pronounsInTimelinesItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_timelines, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInTimelines, 0, i->toggleCheckableItem(pronounsInTimelinesItem)), @@ -102,6 +111,8 @@ protected void onHidden(){ boolean restartPlease=GlobalUserPreferences.disableM3PillActiveIndicator!=disablePillItem.checked || GlobalUserPreferences.showNavigationLabels!=showNavigationLabelsItem.checked + || GlobalUserPreferences.showMediaPreview!=showMediaPreviewItem.checked + || GlobalUserPreferences.showDividers!=showPostDividersItem.checked || GlobalUserPreferences.likeIcon!=likeIconItem.checked; lp.revealCWs=revealCWsItem.checked; @@ -110,8 +121,11 @@ protected void onHidden(){ lp.customEmojiInNames=emojiInNamesItem.checked; lp.save(); GlobalUserPreferences.toolbarMarquee=marqueeItem.checked; + GlobalUserPreferences.relocatePublishButton=relocatePublishButtonItem.checked; GlobalUserPreferences.reduceMotion=reduceMotionItem.checked; GlobalUserPreferences.disableSwipe=disableSwipeItem.checked; + GlobalUserPreferences.doubleTapToSearch=enableDoubleTapToSearchItem.checked; + GlobalUserPreferences.doubleTapToSwipe=enableDoubleTapToSwipeItem.checked; GlobalUserPreferences.showAltIndicator=altIndicatorItem.checked; GlobalUserPreferences.showNoAltIndicator=noAltIndicatorItem.checked; GlobalUserPreferences.collapseLongPosts=collapsePostsItem.checked; @@ -120,11 +134,13 @@ protected void onHidden(){ GlobalUserPreferences.translateButtonOpenedOnly=translateOpenedItem.checked; GlobalUserPreferences.likeIcon=likeIconItem.checked; GlobalUserPreferences.underlinedLinks=underlinedLinksItem.checked; + GlobalUserPreferences.showDividers=showPostDividersItem.checked; GlobalUserPreferences.disableM3PillActiveIndicator=disablePillItem.checked; GlobalUserPreferences.showNavigationLabels=showNavigationLabelsItem.checked; GlobalUserPreferences.displayPronounsInTimelines=pronounsInTimelinesItem.checked; GlobalUserPreferences.displayPronounsInThreads=pronounsInThreadsItem.checked; GlobalUserPreferences.displayPronounsInUserListings=pronounsInUserListingsItem.checked; + GlobalUserPreferences.showMediaPreview=showMediaPreviewItem.checked; GlobalUserPreferences.save(); if(restartPlease) restartActivityToApplyNewTheme(); else E.post(new StatusDisplaySettingsChangedEvent(accountID)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java index d359a7f60d..c126c3986b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -59,7 +59,7 @@ public void onCreate(Bundle savedInstanceState){ new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_fluent_shield_24_regular, this::onPrivacyClick), new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_fluent_alert_24_regular, this::onNotificationsClick), new ListItem<>(R.string.sk_settings_instance, 0, R.drawable.ic_fluent_server_24_regular, this::onInstanceClick), - new ListItem<>(getString(R.string.about_app, getString(R.string.sk_app_name)), null, R.drawable.ic_fluent_info_24_regular, this::onAboutClick, null, 0, true), + new ListItem<>(getString(R.string.about_app, getString(R.string.mo_app_name)), null, R.drawable.ic_fluent_info_24_regular, this::onAboutClick, null, 0, true), new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_fluent_person_swap_24_regular, this::onManageAccountsClick), new ListItem<>(R.string.log_out, 0, R.drawable.ic_fluent_sign_out_24_regular, this::onLogOutClick, R.attr.colorM3Error, false) )); @@ -166,6 +166,7 @@ private void onManageAccountsClick(ListItem item){ private void onLogOutClick(ListItem item_){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.log_out) .setMessage(getString(R.string.confirm_log_out, session.getFullUsername())) .setPositiveButton(R.string.log_out, (dialog, which)->account.logOut(getActivity(), ()->{ loggedOut=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java index 2b240629b7..b87a0c1f29 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java @@ -60,6 +60,9 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ private CheckableListItem uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem; private CheckableListItem postsItem, updateItem; + // MOSHIDON + private CheckableListItem swapBookmarkWithReblogItem; + private AccountLocalPreferences lp; @Override @@ -83,7 +86,8 @@ public void onCreate(Bundle savedInstanceState){ updateItem=new CheckableListItem<>(R.string.sk_notification_type_update, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.update, R.drawable.ic_fluent_history_24_regular, i->toggleCheckableItem(updateItem)), postsItem=new CheckableListItem<>(R.string.sk_notification_type_posts, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.status, R.drawable.ic_fluent_chat_24_regular, i->toggleCheckableItem(postsItem), true), - uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, i->toggleCheckableItem(uniformIconItem)), + uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, R.string.mo_setting_uniform_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, i->toggleCheckableItem(uniformIconItem)), + swapBookmarkWithReblogItem=new CheckableListItem<>(R.string.mo_swap_bookmark_with_reblog, R.string.mo_swap_bookmark_with_reblog_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.swapBookmarkWithBoostAction, R.drawable.ic_boost, i->toggleCheckableItem(swapBookmarkWithReblogItem)), deleteItem=new CheckableListItem<>(R.string.sk_settings_enable_delete_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enableDeleteNotifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, i->toggleCheckableItem(deleteItem)), onlyLatestItem=new CheckableListItem<>(R.string.sk_settings_single_notification, 0, CheckableListItem.Style.SWITCH, lp.keepOnlyLatestNotification, R.drawable.ic_fluent_convert_range_24_regular, i->toggleCheckableItem(onlyLatestItem), true), unifiedPushItem=new CheckableListItem<>(R.string.sk_settings_unifiedpush, 0, CheckableListItem.Style.SWITCH, useUnifiedPush, R.drawable.ic_fluent_alert_arrow_up_24_regular, i->onUnifiedPushClick(), true) @@ -116,6 +120,7 @@ protected void onHidden(){ || pollsItem.checked!=ps.alerts.poll; GlobalUserPreferences.uniformNotificationIcon=uniformIconItem.checked; GlobalUserPreferences.enableDeleteNotifications=deleteItem.checked; + GlobalUserPreferences.swapBookmarkWithBoostAction=swapBookmarkWithReblogItem.checked; GlobalUserPreferences.save(); lp.keepOnlyLatestNotification=onlyLatestItem.checked; lp.save(); @@ -160,12 +165,6 @@ protected RecyclerView.Adapter getAdapter(){ bannerAdapter.setVisible(false); banner.findViewById(R.id.button2).setVisibility(View.GONE); banner.findViewById(R.id.title).setVisibility(View.GONE); - ((RelativeLayout.LayoutParams) bannerText.getLayoutParams()) - .setMargins(0, V.dp(4), 0, 0); - ((RelativeLayout.LayoutParams) bannerIcon.getLayoutParams()) - .addRule(RelativeLayout.CENTER_VERTICAL); - RelativeLayout.LayoutParams buttonParams = (RelativeLayout.LayoutParams) bannerButton.getLayoutParams(); - buttonParams.setMargins(buttonParams.leftMargin, V.dp(-8), buttonParams.rightMargin, V.dp(-12)); mergeAdapter=new MergeRecyclerAdapter(); mergeAdapter.addAdapter(bannerAdapter); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java index 3fd882fafc..ad2c96c598 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java @@ -1,18 +1,25 @@ package org.joinmastodon.android.fragments.settings; +import android.app.Activity; import android.app.Fragment; +import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.FrameLayout; import android.widget.TextView; +import android.view.Menu; +import android.view.MenuInflater; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.CustomLocalTimelineFragment; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; @@ -25,6 +32,7 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.ViewPager2; +import me.grishka.appkit.Nav; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.utils.V; @@ -129,6 +137,39 @@ private Fragment getFragmentForPage(int page){ }; } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + if (instance != null) { + inflater.inflate(R.menu.instance_info, menu); + UiUtils.enableOptionsMenuIcons(getActivity(), menu); + menu.findItem(R.id.share).setTitle(R.string.button_share); + + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.share){ + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, instance.normalizedUri); + startActivity(Intent.createChooser(intent, item.getTitle())); + } else if (id==R.id.open_timeline) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("domain", instance.normalizedUri); + Nav.go(getActivity(), CustomLocalTimelineFragment.class, args); + } + return true; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setHasOptionsMenu(true); + } + @Override public void onApplyWindowInsets(WindowInsets insets){ if(contentView!=null){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java b/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java new file mode 100644 index 0000000000..4168e65051 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/AltTextFilter.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.model; + +import org.jsoup.internal.StringUtil; + +import java.util.EnumSet; + +public class AltTextFilter extends LegacyFilter { + + public AltTextFilter(FilterAction filterAction, FilterContext firstContext, FilterContext... restContexts) { + this.filterAction = filterAction; + isRemote = false; + context = EnumSet.of(firstContext, restContexts); + } + + @Override + public boolean matches(Status status) { + return status.getContentStatus().mediaAttachments.stream().map(attachment -> attachment.description).anyMatch(StringUtil::isBlank); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java new file mode 100644 index 0000000000..85b71c39b3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/CustomLocalTimeline.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.List; + +@Parcel +public class CustomLocalTimeline extends BaseModel{ + @RequiredField + public String domain; + + @Override + public String toString(){ + return "Hashtag{"+ + ", url='"+domain+'\''+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java b/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java new file mode 100644 index 0000000000..00f974c434 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/DomainBlock.java @@ -0,0 +1,27 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class DomainBlock extends BaseModel { + @RequiredField + public String domain; + @RequiredField + public String digest; + @RequiredField + public Severity severity; + public String comment; + + @Override + public String toString() { + return "DomainBlock{" + + "domain='" + domain + '\'' + + ", digest='" + digest + '\'' + + ", severity='" + severity + '\'' + + ", comment='" + comment + '\'' + + '}'; + } + + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java new file mode 100644 index 0000000000..c82f4ad6d1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ExtendedDescription.java @@ -0,0 +1,21 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +import java.util.List; + +@Parcel +public class ExtendedDescription extends BaseModel{ + @RequiredField + public String content; + public String updatedAt; + + @Override + public String toString() { + return "ExtendedDescription{" + + "content='" + content + '\'' + + ", updatedAt='" + updatedAt + '\'' + + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java index 4f43a51a8b..52b9362439 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java @@ -2,8 +2,9 @@ public enum NotificationAction { FAVORITE, - REBLOG, - UNDO_REBLOG, + BOOST, + UNBOOST, BOOKMARK, REPLY, + FOLLOW_BACK } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java b/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java new file mode 100644 index 0000000000..96ffb8d67a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Severity.java @@ -0,0 +1,12 @@ +package org.joinmastodon.android.model; + +import com.google.gson.annotations.SerializedName; + +import org.parceler.Parcel; + +public enum Severity { + @SerializedName("silence") + SILENCE, + @SerializedName("suspend") + SUSPEND +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index eb13a43fcd..5134bc7de1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -3,11 +3,14 @@ import static org.joinmastodon.android.api.MastodonAPIController.gson; import static org.joinmastodon.android.api.MastodonAPIController.gsonWithoutDeserializer; +import androidx.annotation.Nullable; + import android.text.TextUtils; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.ui.text.HtmlParser; import org.parceler.Parcel; @@ -74,6 +77,8 @@ public class Status extends BaseModel implements DisplayItemsParent, Searchable{ public Card card; public String language; public String text; + @Nullable + public Account rebloggedBy; public boolean localOnly; public boolean favourited; @@ -187,6 +192,10 @@ public void update(StatusCountersUpdatedEvent ev){ pinned=ev.pinned; } + public void update(StatusMuteChangedEvent ev) { + muted=ev.muted; + } + public void update(EmojiReactionsUpdatedEvent ev){ reactions=ev.reactions; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java index ddab996629..ac896d5b13 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -9,7 +9,9 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.CustomLocalTimelineFragment; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HashtagTimelineFragment; @@ -34,6 +36,7 @@ public class TimelineDefinition { private @Nullable String listTitle; private boolean listIsExclusive; + private @Nullable String domain; private @Nullable String hashtagName; private @Nullable List hashtagAny; private @Nullable List hashtagAll; @@ -58,6 +61,12 @@ public static TimelineDefinition ofHashtag(String hashtag) { return def; } + public static TimelineDefinition ofCustomLocalTimeline(String domain) { + TimelineDefinition def = new TimelineDefinition(TimelineType.CUSTOM_LOCAL_TIMELINE); + def.domain = domain; + return def; + } + public static TimelineDefinition ofHashtag(Hashtag hashtag) { return ofHashtag(hashtag.name); } @@ -138,6 +147,7 @@ public String getDefaultTitle(Context ctx) { case LIST -> listTitle; case HASHTAG -> hashtagName; case BUBBLE -> ctx.getString(R.string.sk_timeline_bubble); + case CUSTOM_LOCAL_TIMELINE -> domain; }; } @@ -149,6 +159,7 @@ public Icon getDefaultIcon() { case POST_NOTIFICATIONS -> Icon.POST_NOTIFICATIONS; case LIST -> listIsExclusive ? Icon.EXCLUSIVE_LIST : Icon.LIST; case HASHTAG -> Icon.HASHTAG; + case CUSTOM_LOCAL_TIMELINE -> Icon.CUSTOM_LOCAL_TIMELINE; case BUBBLE -> Icon.BUBBLE; }; } @@ -162,6 +173,7 @@ public Fragment getFragment() { case HASHTAG -> new HashtagTimelineFragment(); case POST_NOTIFICATIONS -> new NotificationsListFragment(); case BUBBLE -> new BubbleTimelineFragment(); + case CUSTOM_LOCAL_TIMELINE -> new CustomLocalTimelineFragment(); }; } @@ -186,6 +198,7 @@ public boolean equals(Object o) { TimelineDefinition that = (TimelineDefinition) o; if (type != that.type) return false; if (type == TimelineType.LIST) return Objects.equals(listId, that.listId); + if (type == TimelineType.CUSTOM_LOCAL_TIMELINE) return Objects.equals(domain.toLowerCase(), that.domain.toLowerCase()); if (type == TimelineType.HASHTAG) { if (hashtagName == null && that.hashtagName == null) return true; if (hashtagName == null || that.hashtagName == null) return false; @@ -206,6 +219,7 @@ public TimelineDefinition copy() { def.listTitle = listTitle; def.listIsExclusive = listIsExclusive; def.hashtagName = hashtagName; + def.domain = domain; def.hashtagAny = hashtagAny; def.hashtagAll = hashtagAll; def.hashtagNone = hashtagNone; @@ -224,11 +238,13 @@ public Bundle populateArguments(Bundle args) { args.putStringArrayList("any", hashtagAny == null ? new ArrayList<>() : new ArrayList<>(hashtagAny)); args.putStringArrayList("all", hashtagAll == null ? new ArrayList<>() : new ArrayList<>(hashtagAll)); args.putStringArrayList("none", hashtagNone == null ? new ArrayList<>() : new ArrayList<>(hashtagNone)); + } else if (type == TimelineType.CUSTOM_LOCAL_TIMELINE) { + args.putString("domain", domain); } return args; } - public enum TimelineType { HOME, LOCAL, FEDERATED, POST_NOTIFICATIONS, LIST, HASHTAG, BUBBLE } + public enum TimelineType { HOME, LOCAL, FEDERATED, POST_NOTIFICATIONS, LIST, HASHTAG, CUSTOM_LOCAL_TIMELINE, BUBBLE } public enum Icon { HEART(R.drawable.ic_fluent_heart_24_regular, R.string.sk_icon_heart), @@ -301,6 +317,7 @@ public enum Icon { LIST(R.drawable.ic_fluent_people_24_regular, R.string.sk_list, true), EXCLUSIVE_LIST(R.drawable.ic_fluent_rss_24_regular, R.string.sk_exclusive_list, true), HASHTAG(R.drawable.ic_fluent_number_symbol_24_regular, R.string.sk_hashtag, true), + CUSTOM_LOCAL_TIMELINE(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true), BUBBLE(R.drawable.ic_fluent_circle_24_regular, R.string.sk_timeline_bubble, true); public final int iconRes, nameRes; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Translation.java b/mastodon/src/main/java/org/joinmastodon/android/model/Translation.java index 68487451d7..0bbe596cea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Translation.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Translation.java @@ -1,10 +1,30 @@ package org.joinmastodon.android.model; -import org.joinmastodon.android.api.AllFieldsAreRequired; -@AllFieldsAreRequired +import org.joinmastodon.android.api.RequiredField; + public class Translation extends BaseModel{ + @RequiredField public String content; + @RequiredField public String detectedSourceLanguage; + @RequiredField public String provider; + public String spoilerText; + public MediaAttachment[] mediaAttachments; + public PollTranslation poll; + + public static class MediaAttachment { + public String id; + public String description; + } + + public static class PollTranslation { + public String id; + public PollOption[] options; + } + + public static class PollOption { + public String title; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java b/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java new file mode 100644 index 0000000000..58e794acc1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/WeeklyActivity.java @@ -0,0 +1,26 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; + +@Parcel +public class WeeklyActivity extends BaseModel { + @RequiredField + public String week; + @RequiredField + public int statuses; + @RequiredField + public int logins; + @RequiredField + public int registrations; + + @Override + public String toString() { + return "WeeklyActivity{" + + "week=" + week + + ", statuses=" + statuses + + ", logins=" + logins + + ", registrations=" + registrations + + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java index 889932c75e..ea8603757f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java @@ -132,6 +132,7 @@ public void setOnClick(BiConsumer onClick) { private void confirmLogOut(String accountID){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(activity) + .setTitle(R.string.log_out) .setMessage(activity.getString(R.string.confirm_log_out, session.getFullUsername())) .setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID)) .setNegativeButton(R.string.cancel, null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java index 0b98ff9f60..51de87f65b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java @@ -113,7 +113,7 @@ public int getSpanSize(int position){ List recentEmoji=new ArrayList<>(lp.recentCustomEmoji); if(!recentEmoji.isEmpty()) - adapter.addAdapter(new SingleCategoryAdapter(recentEmojiCategory=new EmojiCategory(activity.getString(R.string.sk_recently_used), recentEmoji))); + adapter.addAdapter(new SingleCategoryAdapter(recentEmojiCategory=new EmojiCategory(activity.getString(R.string.mo_emoji_recent), recentEmoji))); for(EmojiCategory category:emojis) adapter.addAdapter(new SingleCategoryAdapter(category)); @@ -349,7 +349,7 @@ public EmojiViewHolder(boolean isRecentEmojiCategory, List r ImageView img=(ImageView) itemView; img.setLayoutParams(new RecyclerView.LayoutParams(V.dp(48), V.dp(48))); img.setScaleType(ImageView.ScaleType.FIT_CENTER); - int pad=V.dp(12); + int pad=V.dp(6); img.setPadding(pad, pad, pad, pad); img.setBackgroundResource(R.drawable.bg_custom_emoji); this.isRecentEmojiCategory=isRecentEmojiCategory; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java index 0f386d5050..0e176c6e8e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java @@ -65,7 +65,7 @@ public AlertDialog create(){ View title=alert.findViewById(titleID); if(title!=null){ int pad=V.dp(24); - title.setPadding(pad, pad, pad, pad); + title.setPadding(pad, pad, pad, V.dp(18)); } } int titleDividerID=getContext().getResources().getIdentifier("titleDividerNoCustom", "id", "android"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java index fd753ef77d..0321d615b4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountCardStatusDisplayItem.java @@ -201,6 +201,8 @@ private void onActionButtonClick(View v){ private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + if(visible) + actionProgress.setIndeterminateTintList(actionButton.getTextColors()); actionButton.setClickable(!visible); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java index 5a2c6810a4..b55f8a8bca 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java @@ -17,6 +17,7 @@ import org.joinmastodon.android.R; 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.BaseStatusListFragment; import org.joinmastodon.android.fragments.StatusEditHistoryFragment; import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index bf0cce7a49..651dae830b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -6,13 +6,21 @@ import android.content.Intent; import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; +import android.os.Build; import android.os.Bundle; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.accessibility.AccessibilityNodeInfo; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.RotateAnimation; import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageView; @@ -57,6 +65,8 @@ public static class Holder extends StatusDisplayItem.Holder { + UiUtils.opacityIn(v); + Bundle args=new Bundle(); + args.putString("account", item.accountID); + args.putParcelable("replyTo", Parcels.wrap(status)); + Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); + } + ); + return; + } UiUtils.opacityIn(v); Bundle args=new Bundle(); args.putString("account", item.accountID); @@ -209,7 +239,21 @@ private void onBoostClick(View v){ onBoostLongClick(v); return; } + if(item.status.isRemote){ + UiUtils.lookupStatus(v.getContext(), + item.status, item.accountID, null, + status -> { + if(status == null) + return; + boost.setSelected(!status.reblogged); + vibrateForAction(boost, !status.reblogged); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->boostConsumer(v, r)); + } + ); + return; + } boost.setSelected(!item.status.reblogged); + vibrateForAction(boost, !item.status.reblogged); AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged, null, r->boostConsumer(v, r)); } @@ -226,9 +270,22 @@ private boolean onBoostLongClick(View v){ Consumer doReblog = (visibility) -> { UiUtils.opacityOut(v); - session.getStatusInteractionController() - .setReblogged(item.status, !item.status.reblogged, visibility, r->boostConsumer(v, r)); - dialog.dismiss(); + if(item.status.isRemote){ + UiUtils.lookupStatus(v.getContext(), + item.status, item.accountID, null, + status -> { + session.getStatusInteractionController() + .setReblogged(status, !status.reblogged, visibility, r->boostConsumer(v, r)); + boost.setSelected(status.reblogged); + dialog.dismiss(); + } + ); + } else { + session.getStatusInteractionController() + .setReblogged(item.status, !item.status.reblogged, visibility, r->boostConsumer(v, r)); + boost.setSelected(item.status.reblogged); + dialog.dismiss(); + } }; View separator = menu.findViewById(R.id.separator); @@ -301,8 +358,31 @@ private boolean onBoostLongClick(View v){ } private void onFavoriteClick(View v){ + if(item.status.isRemote){ + UiUtils.lookupStatus(v.getContext(), + item.status, item.accountID, null, + status -> { + if(status == null) + return; + favorite.setSelected(!status.favourited); + vibrateForAction(favorite, !status.favourited); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{ + if (status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) { + v.startAnimation(spin); + } + UiUtils.opacityIn(v); + bindText(favorites, r.favouritesCount); + }); + } + ); + return; + } favorite.setSelected(!item.status.favourited); + vibrateForAction(favorite, !item.status.favourited); AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(item.status, !item.status.favourited, r->{ + if (item.status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) { + v.startAnimation(spin); + } UiUtils.opacityIn(v); bindText(favorites, r.favouritesCount); }); @@ -323,7 +403,23 @@ private boolean onFavoriteLongClick(View v) { } private void onBookmarkClick(View v){ + if(item.status.isRemote){ + UiUtils.lookupStatus(v.getContext(), + item.status, item.accountID, null, + status -> { + if(status == null) + return; + bookmark.setSelected(!status.bookmarked); + vibrateForAction(bookmark, !status.bookmarked); + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked, r->{ + UiUtils.opacityIn(v); + }); + } + ); + return; + } bookmark.setSelected(!item.status.bookmarked); + vibrateForAction(bookmark, !item.status.bookmarked); AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked, r->{ UiUtils.opacityIn(v); }); @@ -369,5 +465,27 @@ private int descriptionForId(int id){ return R.string.button_share; return 0; } + + private static void vibrateForAction(View view, boolean isPositive) { + if (!GlobalUserPreferences.hapticFeedback) return; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(isPositive ? HapticFeedbackConstants.CONFIRM : HapticFeedbackConstants.REJECT); + } else { + Vibrator vibrator = view.getContext().getSystemService(Vibrator.class); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(isPositive ? VibrationEffect.EFFECT_CLICK : VibrationEffect.EFFECT_DOUBLE_CLICK)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect effect = isPositive + ? VibrationEffect.createOneShot(75L, 128) + : VibrationEffect.createWaveform(new long[]{0L, 75L, 75L, 75L}, new int[]{0, 128, 0, 128}, -1); + vibrator.vibrate(effect); + } else { + if (isPositive) vibrator.vibrate(75L); + else vibrator.vibrate(new long[]{0L, 75L, 75L, 75L}, -1); + } + } + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index 6f1b95c2af..b5c447d0e4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -38,6 +38,7 @@ import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Announcement; +import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.ScheduledStatus; @@ -56,6 +57,7 @@ import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.function.Consumer; import androidx.annotation.LayoutRes; @@ -247,6 +249,8 @@ public void onError(ErrorResponse error){ UiUtils.confirmPinPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, !item.status.pinned, s->{}); }else if(id==R.id.mute){ UiUtils.confirmToggleMuteUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.muting, r->{}); + }else if (id==R.id.mute_conversation || id==R.id.unmute_conversation) { + UiUtils.confirmToggleMuteConversation(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, ()->{}); }else if(id==R.id.block){ UiUtils.confirmToggleBlockUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.blocking, r->{}); }else if(id==R.id.report){ @@ -440,6 +444,14 @@ private void onAvaClick(View v){ return; } Bundle args=new Bundle(); + if(item.status != null && item.status.isRemote){ + UiUtils.lookupAccount(v.getContext(), item.status.account, item.accountID, null, account -> { + args.putString("account", item.accountID); + args.putParcelable("profileAccount", Parcels.wrap(account)); + Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args); + }); + return; + } args.putString("account", item.accountID); args.putParcelable("profileAccount", Parcels.wrap(item.user)); Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args); @@ -498,6 +510,14 @@ private void updateOptionsMenu(){ menu.findItem(R.id.delete_and_redraft).setVisible(!isPostScheduled && item.status!=null && isOwnPost); menu.findItem(R.id.pin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && !item.status.pinned); menu.findItem(R.id.unpin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && item.status.pinned); + menu.findItem(R.id.mute_conversation).setVisible((item.status!=null && !item.status.muted && !isPostScheduled) && (isOwnPost || item.status.mentions.stream().anyMatch(m->{ + if(m==null) + return false; + return AccountSessionManager.get(item.parentFragment.getAccountID()).self.id.equals(m.id) || + AccountSessionManager.get(item.parentFragment.getAccountID()).self.getFullyQualifiedName().equals(m.username) || + AccountSessionManager.get(item.parentFragment.getAccountID()).self.acct.equals(m.acct); + }))); + menu.findItem(R.id.unmute_conversation).setVisible(item.status!=null && item.status.muted); menu.findItem(R.id.open_in_browser).setVisible(!isPostScheduled && item.status!=null); menu.findItem(R.id.copy_link).setVisible(!isPostScheduled && item.status!=null); MenuItem blockDomain=menu.findItem(R.id.block_domain); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java index 84dc32df70..afba9b2fe8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/LinkCardStatusDisplayItem.java @@ -27,10 +27,10 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{ private final Status status; private final UrlImageLoaderRequest imgRequest; - public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){ + public LinkCardStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, boolean showImagePreview){ super(parentID, parentFragment); this.status=status; - if(status.card.image!=null) + if(status.card.image!=null && showImagePreview) imgRequest=new UrlImageLoaderRequest(status.card.image, 1000, 1000); else imgRequest=null; @@ -78,6 +78,7 @@ public void onBind(LinkCardStatusDisplayItem item){ photo.setImageDrawable(null); if(item.imgRequest!=null){ + crossfadeDrawable.setSize(card.width, card.height); if (card.width > 0) { // akkoma servers don't provide width and height crossfadeDrawable.setSize(card.width, card.height); @@ -121,3 +122,4 @@ private void onClick(View v){ } } } + diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java index 0d066fdf38..bc1d016895 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/MediaGridStatusDisplayItem.java @@ -12,6 +12,7 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.text.TextUtils; +import android.util.Pair; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; @@ -26,6 +27,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.Translation; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; @@ -38,7 +40,12 @@ import org.joinmastodon.android.utils.TypedObjectPool; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -52,6 +59,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{ private PhotoLayoutHelper.TiledLayoutResult tiledLayout; private final TypedObjectPool viewPool; private final List attachments; + private final Map> translatedAttachments = new HashMap<>(); private final ArrayList requests=new ArrayList<>(); public final Status status; public String sensitiveTitle; @@ -65,8 +73,8 @@ public MediaGridStatusDisplayItem(String parentID, BaseStatusListFragment par for(Attachment att:attachments){ requests.add(new UrlImageLoaderRequest(switch(att.type){ case IMAGE -> att.url; - case VIDEO, GIFV -> att.previewUrl != null ? att.previewUrl : att.url; - default -> throw new IllegalStateException("Unexpected value: "+att.type); + case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl; + default -> throw new IllegalStateException("Unexpected value: "+att.url); }, 1000, 1000)); } } @@ -189,6 +197,25 @@ public void onBind(MediaGridStatusDisplayItem item){ c.btnsWrap.setAlpha(1f); } controllers.add(c); + + if (item.status.translation != null){ + if(item.status.translationState==Status.TranslationState.SHOWN){ + if(!item.translatedAttachments.containsKey(att.id)){ + Optional translatedAttachment=Arrays.stream(item.status.translation.mediaAttachments).filter(mediaAttachment->mediaAttachment.id.equals(att.id)).findFirst(); + translatedAttachment.ifPresent(mediaAttachment->{ + item.translatedAttachments.put(mediaAttachment.id, new Pair<>(att.description, mediaAttachment.description)); + att.description=mediaAttachment.description; + }); + }else{ + //SAFETY: must be non-null, as we check if the map contains the attachment before + att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).second; + } + }else{ + if (item.translatedAttachments.containsKey(att.id)) { + att.description=Objects.requireNonNull(item.translatedAttachments.get(att.id)).first; + } + } + } c.bind(att, item.status); i++; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java index df87dae506..a91f5170be 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java @@ -115,7 +115,6 @@ public ImageLoaderRequest getImageRequest(int index){ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final ImageView icon, avatar, deleteNotification; private final TextView text, timestamp; - private final int selectableItemBackground; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_notification_header, parent); @@ -133,10 +132,8 @@ public Holder(Activity activity, ViewGroup parent){ } })); - itemView.setOnClickListener(this::onItemClick); - TypedValue outValue = new TypedValue(); - context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); - selectableItemBackground = outValue.resourceId; + icon.setOnClickListener(this::onItemClick); + avatar.setOnClickListener(this::onItemClick); } @Override @@ -183,12 +180,7 @@ public void onBind(NotificationHeaderStatusDisplayItem item){ default -> android.R.attr.colorAccent; }))); deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification != null ? View.VISIBLE : View.GONE); - itemView.setBackgroundResource(item.notification.type != Notification.Type.POLL - && item.notification.type != Notification.Type.REPORT ? - selectableItemBackground : 0); - itemView.setClickable(item.notification.type != Notification.Type.POLL); - itemView.setPaddingRelative(itemView.getPaddingStart(), itemView.getPaddingTop(), - GlobalUserPreferences.enableDeleteNotifications ? V.dp(4) : V.dp(16), itemView.getPaddingBottom()); + itemView.setBackgroundResource(0); } public void onItemClick(View v) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java index 2ec3733359..6d86065e0a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollFooterStatusDisplayItem.java @@ -10,14 +10,17 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Poll; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.UiUtils; public class PollFooterStatusDisplayItem extends StatusDisplayItem{ public final Poll poll; + public final Status status; - public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll){ + public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll, Status status){ super(parentID, parentFragment); this.poll=poll; + this.status=status; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java index a1eae6f995..9be62317b9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PollOptionStatusDisplayItem.java @@ -11,7 +11,9 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Poll; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; @@ -23,6 +25,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ private CharSequence text; + private CharSequence translatedText; public final Poll.Option option; private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); private boolean showResults; @@ -30,12 +33,15 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{ private boolean isMostVoted; private final int optionIndex; public final Poll poll; + public final Status status; - public PollOptionStatusDisplayItem(String parentID, Poll poll, int optionIndex, BaseStatusListFragment parentFragment){ + + public PollOptionStatusDisplayItem(String parentID, Poll poll, int optionIndex, BaseStatusListFragment parentFragment, Status status){ super(parentID, parentFragment); this.optionIndex=optionIndex; option=poll.options.get(optionIndex); this.poll=poll; + this.status=status; text=HtmlParser.parseCustomEmoji(option.title, poll.emojis); emojiHelper.setText(text); showResults=poll.isExpired() || poll.voted; @@ -84,7 +90,14 @@ public Holder(Activity activity, ViewGroup parent){ @Override public void onBind(PollOptionStatusDisplayItem item){ - text.setText(item.text); + if (item.status.translation != null && item.status.translationState == Status.TranslationState.SHOWN) { + if(item.translatedText==null){ + item.translatedText=item.status.translation.poll.options[item.optionIndex].title; + } + text.setText(item.translatedText); + } else { + text.setText(item.text); + } percent.setVisibility(item.showResults ? View.VISIBLE : View.GONE); itemView.setClickable(!item.showResults); icon.setImageDrawable(itemView.getContext().getDrawable(item.poll.multiple ? diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java new file mode 100644 index 0000000000..e6dd9f389a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/PreviewlessMediaGridStatusDisplayItem.java @@ -0,0 +1,146 @@ +package org.joinmastodon.android.ui.displayitems; + +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.PhotoLayoutHelper; +import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController; +import org.joinmastodon.android.ui.views.FrameLayoutThatOnlyMeasuresFirstChild; +import org.joinmastodon.android.utils.TypedObjectPool; + +import java.util.ArrayList; +import java.util.List; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class PreviewlessMediaGridStatusDisplayItem extends StatusDisplayItem{ + private static final String TAG="PreviewlessMediaGridDisplayItem"; + + private PhotoLayoutHelper.TiledLayoutResult tiledLayout; + private final TypedObjectPool viewPool; + private final List attachments; + private final ArrayList requests=new ArrayList<>(); + public final Status status; + public String sensitiveTitle; + + public PreviewlessMediaGridStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, PhotoLayoutHelper.TiledLayoutResult tiledLayout, List attachments, Status status){ + super(parentID, parentFragment); + this.tiledLayout=tiledLayout; + this.viewPool=parentFragment.getPreviewlessAttachmentViewsPool(); + this.attachments=attachments; + this.status=status; +// for(Attachment att:attachments){ +// requests.add(new UrlImageLoaderRequest(switch(att.type){ +// case IMAGE -> att.url; +// case VIDEO, GIFV -> att.previewUrl == null ? att.url : att.previewUrl; +// default -> throw new IllegalStateException("Unexpected value: "+att.url); +// }, 1000, 1000)); +// } + } + + @Override + public Type getType(){ + return Type.PREVIEWLESS_MEDIA_GRID; + } + + @Override + public int getImageCount(){ + return attachments.size(); + } + + @Override + public ImageLoaderRequest getImageRequest(int index){ + return requests.get(index); + } + + public static class Holder extends StatusDisplayItem.Holder { + private final FrameLayout wrapper; + private final LinearLayout layout; + private final View.OnClickListener clickListener=this::onViewClick; + private final ArrayList controllers=new ArrayList<>(); + + // private final FrameLayout hideSensitiveButton; + + public Holder(Activity activity, ViewGroup parent){ + super(new FrameLayoutThatOnlyMeasuresFirstChild(activity)); + wrapper=(FrameLayout)itemView; + layout= new LinearLayout(activity); + layout.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + layout.setLayoutParams(params); + wrapper.addView(layout); + wrapper.setClipToPadding(false); + + // megalodon: no sensitive hide button because the visibility toggle looks prettier imo +// hideSensitiveButton=(FrameLayout) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false); +// ((TextView) hideSensitiveButton.findViewById(R.id.alt_button)).setText(R.string.hide); +// overlays.addView(hideSensitiveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP)); + +// hideSensitiveButton.setOnClickListener(v->hideSensitive()); + } + + @Override + public void onBind(PreviewlessMediaGridStatusDisplayItem item){ + wrapper.setPadding(0, 0, 0, 0); // item.inset ? 0 : V.dp(8)); + +// if(altTextAnimator!=null) +// altTextAnimator.cancel(); + + for(PreviewlessMediaAttachmentViewController c:controllers){ + item.viewPool.reuse(c.type, c); + } + layout.removeAllViews(); + controllers.clear(); + + int i=0; +// if (!item.attachments.isEmpty()) updateBlurhashInSensitiveOverlay(); + for(Attachment att:item.attachments){ + PreviewlessMediaAttachmentViewController c=item.viewPool.obtain(switch(att.type){ + case IMAGE -> MediaGridStatusDisplayItem.GridItemType.PHOTO; + case VIDEO -> MediaGridStatusDisplayItem.GridItemType.VIDEO; + case GIFV -> MediaGridStatusDisplayItem.GridItemType.GIFV; + default -> throw new IllegalStateException("Unexpected value: "+att.type); + }); + if(c.view.getLayoutParams()==null) + c.view.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + layout.addView(c.view); + c.view.setOnClickListener(clickListener); + c.view.setTag(i); + controllers.add(c); + c.bind(att, item.status); + i++; + } + + boolean insetAndLast=item.inset && isLastDisplayItemForStatus(); + wrapper.setClipToOutline(insetAndLast); + wrapper.setOutlineProvider(insetAndLast ? OutlineProviders.bottomRoundedRect(12) : null); + } + + private void onViewClick(View v){ + int index=(Integer)v.getTag(); + item.parentFragment.openPreviewlessMediaPhotoViewer(item.parentID, item.status, index, this); + } + + + public PreviewlessMediaAttachmentViewController getViewController(int index){ + return controllers.get(index); + } + + public void setClipChildren(boolean clip){ + layout.setClipChildren(clip); + wrapper.setClipChildren(clip); + } + + public LinearLayout getLayout(){ + return layout; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java index 722bd5c49d..e8feadaae4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java @@ -27,6 +27,7 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{ public final Status status; public final ArrayList contentItems=new ArrayList<>(); private final CharSequence parsedTitle; + private CharSequence translatedTitle; private final CustomEmojiHelper emojiHelper; private final Type type; private final int attachmentCount; @@ -90,7 +91,14 @@ public Holder(Context context, ViewGroup parent, Type type){ @Override public void onBind(SpoilerStatusDisplayItem item){ - title.setText(item.parsedTitle); + if(item.status.translationState==Status.TranslationState.SHOWN){ + if(item.translatedTitle==null){ + item.translatedTitle=item.status.translation.spoilerText; + } + title.setText(item.translatedTitle); + }else{ + title.setText(item.parsedTitle); + } action.setText(item.status.spoilerRevealed ? R.string.spoiler_hide : R.string.sk_spoiler_show); itemView.setPadding( itemView.getPaddingLeft(), diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 9b5995f2d4..5eade5bab6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -40,6 +40,7 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.viewholders.AccountViewHolder; +import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; import java.util.ArrayList; @@ -73,6 +74,7 @@ public abstract class StatusDisplayItem{ public static final int FLAG_NO_HEADER=1 << 4; public static final int FLAG_NO_TRANSLATE=1 << 5; public static final int FLAG_NO_EMOJI_REACTIONS=1 << 6; + public static final int FLAG_NO_MEDIA_PREVIEW=1 << 7; public void setAncestryInfo( boolean hasDescendantNeighbor, @@ -129,6 +131,7 @@ public static BindableViewHolder createViewHolder(T case GAP -> new GapStatusDisplayItem.Holder(activity, parent); case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent); case MEDIA_GRID -> new MediaGridStatusDisplayItem.Holder(activity, parent); + case PREVIEWLESS_MEDIA_GRID -> new PreviewlessMediaGridStatusDisplayItem.Holder(activity, parent); case WARNING -> new WarningFilteredStatusDisplayItem.Holder(activity, parent); case FILE -> new FileStatusDisplayItem.Holder(activity, parent); case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type); @@ -176,6 +179,9 @@ public static ArrayList buildItems(BaseStatusListFragment if(status.reblog!=null){ boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account); + + statusForContent.rebloggedBy = status.account; + String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName()); items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{ args.putParcelable("profileAccount", Parcels.wrap(status.account)); @@ -261,7 +267,7 @@ public static ArrayList buildItems(BaseStatusListFragment } List imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList()); - if(!imageAttachments.isEmpty()){ + if(!imageAttachments.isEmpty() && (flags & FLAG_NO_MEDIA_PREVIEW)==0){ int color = UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant); for (Attachment att : imageAttachments) { if (att.blurhashPlaceholder == null) { @@ -276,6 +282,10 @@ else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLo statusForContent.sensitiveRevealed=true; contentItems.add(mediaGrid); } + if((flags & FLAG_NO_MEDIA_PREVIEW)!=0){ + contentItems.add(new PreviewlessMediaGridStatusDisplayItem(parentID, fragment, null, imageAttachments, statusForContent)); + + } for(Attachment att:statusForContent.mediaAttachments){ if(att.type==Attachment.Type.AUDIO){ contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att)); @@ -285,10 +295,10 @@ else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLo } } if(statusForContent.poll!=null){ - buildPollItems(parentID, fragment, statusForContent.poll, contentItems); + buildPollItems(parentID, fragment, statusForContent.poll, contentItems, statusForContent); } if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty()){ - contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent)); + contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent, (flags & FLAG_NO_MEDIA_PREVIEW)==0)); } if(contentItems!=items && statusForContent.spoilerRevealed){ items.addAll(contentItems); @@ -330,6 +340,10 @@ else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLo } } + // Hide statuses that have a filter action of hide + if(!new StatusFilterPredicate(accountID, filterContext, FilterAction.HIDE).test(status)) + return new ArrayList() ; + List nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items; WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null : new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter); @@ -339,13 +353,13 @@ else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLo ); } - public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, List items){ + public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, List items, Status status){ int i=0; for(Poll.Option opt:poll.options){ - items.add(new PollOptionStatusDisplayItem(parentID, poll, i, fragment)); + items.add(new PollOptionStatusDisplayItem(parentID, poll, i, fragment, status)); i++; } - items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll)); + items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll, status)); } public enum Type{ @@ -364,6 +378,7 @@ public enum Type{ GAP, EXTENDED_FOOTER, MEDIA_GRID, + PREVIEWLESS_MEDIA_GRID, WARNING, FILE, SPOILER, diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java index 5881c66674..5a562cb431 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java @@ -24,7 +24,10 @@ import org.joinmastodon.android.ui.views.LinkedTextView; import java.util.Locale; +import java.util.regex.Pattern; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.MovieDrawable; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java index 5f0867d021..a8e6d4751f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/WarningFilteredStatusDisplayItem.java @@ -3,15 +3,19 @@ import android.content.Context; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.AltTextFilter; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Status; import java.util.List; +// Mind the gap! public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{ public boolean loading; public final Status status; @@ -30,20 +34,31 @@ public Type getType(){ return Type.WARNING; } - public static class Holder extends StatusDisplayItem.Holder{ - public final TextView text; - public List filteredItems; + public static class Holder extends StatusDisplayItem.Holder{ + public final View warningWrap; + public final Button showBtn; + public final TextView text; + public List filteredItems; - public Holder(Context context, ViewGroup parent) { - super(context, R.layout.display_item_filter_warning, parent); - text=findViewById(R.id.text); - } + public Holder(Context context, ViewGroup parent){ + super(context, R.layout.display_item_warning, parent); + warningWrap=findViewById(R.id.warning_wrap); + showBtn=findViewById(R.id.reveal_btn); + showBtn.setOnClickListener(i -> item.parentFragment.onWarningClick(this)); + itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this)); + text=findViewById(R.id.text); + } @Override public void onBind(WarningFilteredStatusDisplayItem item) { filteredItems = item.filteredItems; - text.setText(item.parentFragment.getString(R.string.sk_filtered, item.applyingFilter.title)); - itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this)); + String title = item.applyingFilter instanceof AltTextFilter ? item.parentFragment.getString(R.string.sk_no_alt_text) : item.applyingFilter.title; + text.setText(item.parentFragment.getString(R.string.sk_filtered, title)); } - } + + @Override + public void onClick(){ + + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index 053b55758f..d4fe07cc26 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -147,7 +147,7 @@ public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){ toolbarWrap.setPadding(0, 0, 0, 0); videoControls.setPadding(0, 0, 0, 0); } - insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom); + insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, insets.getSystemWindowInsetBottom()); } uiOverlay.dispatchApplyWindowInsets(insets); int bottomInset=insets.getSystemWindowInsetBottom(); @@ -602,7 +602,7 @@ private String formatTime(int timeSec, boolean includeHours){ private void updateVideoPosition(){ if(videoPositionNeedsUpdating){ - int currentPosition=videoInitialPosition+(int)(SystemClock.uptimeMillis()-videoInitialPositionTime); + int currentPosition=(videoInitialPosition+(int)(SystemClock.uptimeMillis()-videoInitialPositionTime))%videoDuration; videoSeekBar.setProgress(Math.round((float)currentPosition/videoDuration*10000f)); updateVideoTimeText(currentPosition); windowView.postOnAnimation(videoPositionUpdater); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/DiffRemovedSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/DiffRemovedSpan.java new file mode 100644 index 0000000000..6f58dc121c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/DiffRemovedSpan.java @@ -0,0 +1,26 @@ +package org.joinmastodon.android.ui.text; + +import android.text.TextPaint; +import android.text.style.CharacterStyle; + +import org.joinmastodon.android.ui.utils.UiUtils; + +public class DiffRemovedSpan extends CharacterStyle { + + private final String text; + + public DiffRemovedSpan(String text){ + this.text=text; + } + + + @Override + public void updateDrawState(TextPaint tp) { + tp.setStrikeThruText(true); + tp.setColor(0xFFCA5B63); + } + + public String getText() { + return text; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java index 857288abb3..b4b58d1206 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.ui.text; import android.content.Context; +import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.Spanned; @@ -100,7 +101,7 @@ public SpanInfo(Object span, int start, Element element, boolean more){ } } - Map idsByUrl=mentions.stream().distinct().collect(Collectors.toMap(m->m.url, m->m.id)); + Map idsByUrl=mentions.stream().filter(mention -> mention.id != null).collect(Collectors.toMap(m->m.url, m->m.id)); // Hashtags in remote posts have remote URLs, these have local URLs so they don't match. // Map tagsByUrl=tags.stream().collect(Collectors.toMap(t->t.url, t->t.name)); Map tagsByTag=tags.stream().distinct().collect(Collectors.toMap(t->t.name.toLowerCase(), Function.identity())); @@ -171,6 +172,9 @@ public void head(@NonNull Node node, int depth){ } case "code", "pre" -> openSpans.add(new SpanInfo(new TypefaceSpan("monospace"), ssb.length(), el)); case "blockquote" -> openSpans.add(new SpanInfo(new LeadingMarginSpan.Standard(V.dp(10)), ssb.length(), el)); + //fake elements for the edit history diff view + case "edit_diff_added" -> openSpans.add(new SpanInfo(new ForegroundColorSpan(UiUtils.isDarkTheme() ? 0xFF89bb9c : 0xFF5b8e63), ssb.length(), el)); + case "edit_diff_removed" -> openSpans.add(new SpanInfo(new DiffRemovedSpan(el.text()), ssb.length(), el)); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java index 66281119b5..f654f6b34a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java @@ -52,6 +52,10 @@ public void onClick(Context context){ } } + public void onLongClick(View view) { + UiUtils.copyText(view, getType() == Type.URL ? link : text); + } + public String getLink(){ return link; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java index f7e21590b8..dca5ad8ed6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ColorPalette.java @@ -25,8 +25,11 @@ GREEN, new ColorPalette(R.style.ColorPalette_Green), BLUE, new ColorPalette(R.style.ColorPalette_Blue), BROWN, new ColorPalette(R.style.ColorPalette_Brown), RED, new ColorPalette(R.style.ColorPalette_Red), - YELLOW, new ColorPalette(R.style.ColorPalette_Yellow) - ); + YELLOW, new ColorPalette(R.style.ColorPalette_Yellow), + NORD, new ColorPalette(R.style.ColorPalette_Nord), + WHITE, new ColorPalette(R.style.ColorPalette_White) + + ); private @StyleRes int base; private @StyleRes int autoDark; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java index fbee5dc6a1..e69acc17f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/DiscoverInfoBannerHelper.java @@ -89,4 +89,4 @@ public enum BannerType{ ACCOUNTS, BUBBLE_TIMELINE } -} +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java index d64b1d0f3f..cb4a2b08ee 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java @@ -9,6 +9,8 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import java.util.List; @@ -40,9 +42,17 @@ public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull Rec boolean inset=(holder instanceof StatusDisplayItem.Holder sdi) && sdi.getItem().inset; if(inset){ if(rect.isEmpty()){ - rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight()); + if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){ + rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight() + V.dp(4)); + }else { + rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight()); + } }else{ - rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()); + if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){ + rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()) + V.dp(4); + }else { + rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()); + } } }else if(!rect.isEmpty()){ drawInsetBackground(parent, c); @@ -63,7 +73,7 @@ private void drawInsetBackground(RecyclerView list, Canvas c){ paint.setColor(bgColor); rect.left=V.dp(12); rect.right=list.getWidth()-V.dp(12); - rect.inset(V.dp(4), V.dp(0)); + rect.intersect(V.dp(4), V.dp(4), V.dp(4), V.dp(-4)); c.drawRoundRect(rect, V.dp(12), V.dp(12), paint); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(V.dp(1)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java new file mode 100644 index 0000000000..3d27e30a0e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/PreviewlessMediaAttachmentViewController.java @@ -0,0 +1,56 @@ +package org.joinmastodon.android.ui.utils; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; +import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; +import org.joinmastodon.android.ui.drawables.PlayIconDrawable; + +public class PreviewlessMediaAttachmentViewController{ + public final View view; + public final MediaGridStatusDisplayItem.GridItemType type; + private final TextView title, domain; + public final View inner; + private final ImageView icon; + private final Context context; + private Status status; + + public PreviewlessMediaAttachmentViewController(Context context, MediaGridStatusDisplayItem.GridItemType type){ + view=context.getSystemService(LayoutInflater.class).inflate(R.layout.display_item_file, null); + title=view.findViewById(R.id.title); + domain=view.findViewById(R.id.domain); + icon=view.findViewById(R.id.imageView); + inner=view.findViewById(R.id.inner); + this.context=context; + this.type=type; + } + + public void bind(Attachment attachment, Status status){ + this.status=status; + title.setText(attachment.description != null + ? attachment.description + : context.getString(R.string.sk_no_alt_text)); + title.setSingleLine(false); + + domain.setText(status.sensitive ? context.getString(R.string.sensitive_content_explain) : null); + domain.setVisibility(status.sensitive ? View.VISIBLE : View.GONE); + + if(attachment.type == Attachment.Type.IMAGE) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_image_24_regular)); + if(attachment.type == Attachment.Type.VIDEO) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_video_clip_24_regular)); + if(attachment.type == Attachment.Type.GIFV) + icon.setImageDrawable(context.getDrawable(R.drawable.ic_fluent_gif_24_regular)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index 187b89ac4d..dcd0070fe1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -85,10 +85,12 @@ import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.DeleteStatus; import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.requests.statuses.SetStatusMuted; import org.joinmastodon.android.api.requests.statuses.SetStatusPinned; import org.joinmastodon.android.api.session.AccountLocalPreferences; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.StatusMuteChangedEvent; import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.FollowRequestHandledEvent; @@ -140,8 +142,8 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.function.BiConsumer; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Function; @@ -469,11 +471,18 @@ public static void showConfirmationAlert(Context context, @StringRes int title, } public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed) { + showConfirmationAlert(context, title, message, confirmButton, icon, onConfirmed, null); + } + + public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed, Runnable onDenied){ new M3AlertDialogBuilder(context) .setTitle(title) .setMessage(message) - .setPositiveButton(confirmButton, (dlg, i) -> onConfirmed.run()) - .setNegativeButton(R.string.cancel, null) + .setPositiveButton(confirmButton, (dlg, i)->onConfirmed.run()) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + if (onDenied != null) + onDenied.run(); + }) .setIcon(icon) .show(); } @@ -622,7 +631,7 @@ public void onError(ErrorResponse error){ .exec(accountID); }) .setNegativeButton(R.string.cancel, null) - .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_0_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) + .setIcon(currentlyMuted ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular) .show(); } @@ -654,6 +663,32 @@ public void onError(ErrorResponse error) { ); } + public static void confirmToggleMuteConversation(Activity activity, String accountID, Status status, Runnable resultCallback) { + showConfirmationAlert(activity, + status.muted ? R.string.mo_unmute_conversation : R.string.mo_mute_conversation, + status.muted ? R.string.mo_confirm_to_unmute_conversation : R.string.mo_confirm_to_mute_conversation, + status.muted ? R.string.do_unmute : R.string.do_mute, + status.muted ? R.drawable.ic_fluent_alert_28_regular : R.drawable.ic_fluent_alert_off_28_regular, + () -> new SetStatusMuted(status.id, !status.muted) + .setCallback(new Callback(){ + @Override + public void onSuccess(Status result){ + resultCallback.run(); + Toast.makeText(activity, result.muted ? R.string.mo_muted_conversation_successfully : R.string.mo_unmuted_conversation_successfully, Toast.LENGTH_SHORT).show(); + E.post(new StatusMuteChangedEvent(result)); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + } + }) + .wrapProgress(activity, status.muted ? R.string.mo_unmuting : R.string.mo_muting, false) + .exec(accountID) + + ); + } + public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback) { boolean isDraft = status.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT); showConfirmationAlert(activity, @@ -1057,6 +1092,8 @@ public static Optional>> parseFediverseHandle(Stri // // COPIED FROM https://github.com/tuskyapp/Tusky/blob/develop/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt public static boolean looksLikeFediverseUrl(String urlString) { + if(urlString == null) + return false; URI uri; try { uri = new URI(urlString); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java index aa72ec7240..9c3b64e7f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java @@ -42,6 +42,7 @@ public class ComposePollViewController{ 30*60, 3600, 6*3600, + 12*3600, 24*3600, 3*24*3600, 7*24*3600, diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java index 7e709e5e2b..efd2380718 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java @@ -264,7 +264,7 @@ private void onButtonClick(View v){ private void setActionProgressVisible(boolean visible){ if(visible) actionProgress.setIndeterminateTintList(button.getTextColors()); -// TODO button.setTextVisible(!visible); + button.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); button.setClickable(!visible); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java index 0389791006..e455a69b31 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java +++ b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java @@ -21,7 +21,7 @@ public static GithubSelfUpdater getInstance(){ } public static boolean needSelfUpdating(){ - return BuildConfig.BUILD_TYPE.equals("githubRelease") || BuildConfig.BUILD_TYPE.equals("debug"); + return BuildConfig.BUILD_TYPE.equals("githubRelease") || BuildConfig.BUILD_TYPE.equals("debug") || BuildConfig.BUILD_TYPE.equals("nightly"); } public abstract void checkForUpdates(); @@ -53,8 +53,8 @@ public enum UpdateState{ } public static class UpdateInfo{ - public String version; public String changelog; + public String version; public long size; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/FileProvider.java b/mastodon/src/main/java/org/joinmastodon/android/utils/FileProvider.java new file mode 100644 index 0000000000..e6336260a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/FileProvider.java @@ -0,0 +1,956 @@ +/* + * 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.utils; + +import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT; +import static org.xmlpull.v1.XmlPullParser.START_TAG; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Service; +import android.content.ClipData; +import android.content.ContentProvider; +import android.content.ContentResolver; +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.DoNotInline; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.XmlRes; + +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 content:// {@link Uri} for a file + * instead of a file:/// {@link Uri}. + *

+ * 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 Activity} is active. For an {@link Intent} going to a + * {@link Service}, the permissions are available as long as the + * {@link Service} is running. + *

+ * In comparison, to control access to a file:/// {@link Uri} you have to modify the + * file system permissions of the underlying file. The permissions you provide become available to + * any app, and remain in effect until you change them. This level of access is + * fundamentally insecure. + *

+ * The increased level of file access security offered by a content URI + * makes FileProvider a key part of Android's security infrastructure. + *

+ * This overview of FileProvider includes the following topics: + *

+ *
    + *
  1. Defining a FileProvider
  2. + *
  3. Specifying Available Files
  4. + *
  5. Generating the Content URI for a File
  6. + *
  7. Granting Temporary Permissions to a URI
  8. + *
  9. Serving a Content URI to Another App
  10. + *
+ *

+ * Defining a FileProvider + *

+ * Extend FileProvider with a default constructor, and call super with an XML resource file that + * specifies the available files (see below for the structure of the XML file): + *

+ * public class MyFileProvider extends FileProvider {
+ *    public MyFileProvider() {
+ *        super(R.xml.file_paths)
+ *    }
+ * }
+ * 
+ * Add a + * <provider> + * element to your app manifest. Set the android:name attribute to the FileProvider you + * created. Set the android:authorities attribute to a URI authority based on a + * domain you control; for example, if you control the domain mydomain.com you + * should use the authority com.mydomain.fileprovider. Set the + * android:exported attribute to false; the FileProvider does not need + * to be public. Set the android:grantUriPermissions attribute to true, to allow you to grant temporary + * access to files. For example: + *
+ * <manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <provider
+ *            android:name="com.sample.MyFileProvider"
+ *            android:authorities="com.mydomain.fileprovider"
+ *            android:exported="false"
+ *            android:grantUriPermissions="true">
+ *            ...
+ *        </provider>
+ *        ...
+ *    </application>
+ * </manifest>
+ *

+ * It is possible to use FileProvider directly instead of extending it. However, this is not + * reliable and will causes crashes on some devices. + *

+ * Specifying Available Files + *

+ * A FileProvider can only generate a content URI for files in directories that you specify + * beforehand. To specify a directory, specify its storage area and path in XML, using child + * elements of the <paths> element. + * For example, the following paths element tells FileProvider that you intend to + * request content URIs for the images/ subdirectory of your private file area. + *

+ * <paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    ...
+ * </paths>
+ * 
+ *

+ * The <paths> element must contain one or more of the following child elements: + *

    + *
  • + *
    <files-path name="name" path="path" />
    + * Represents files in the files/ subdirectory of your app's internal storage + * area. This subdirectory is the same as the value returned by {@link Context#getFilesDir() + * Context.getFilesDir()}. + *
  • + *
  • <cache-path name="name" path="path" />
    + * 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()}. + *
  • + *
    <external-path name="name" path="path" />
    + * 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()}. + *
  • + *
    <external-files-path name="name" path="path"
    + *     />
    + * 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 + * {@link Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)}. + *
  • + *
  • + *
    <external-cache-path name="name" path="path"
    + *     />
    + * 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()}. + *
  • + *
    <external-media-path name="name" path="path"
    + *     />
    + * 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()}. + *

    Note: this directory is only available on API 21+ devices.

    + *
  • + *
+ *

+ * These child elements all use the same attributes: + *

    + *
  • + * name="name" + *

    + * 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 + * path attribute. + *

  • + *
  • + * path="path" + *

    + * The subdirectory you're sharing. While the name attribute is a URI path + * segment, the path value is an actual subdirectory name. Notice that the + * value refers to a subdirectory, 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. + *

  • + *
+ *

+ * You must specify a child element of <paths> for each directory that contains + * files for which you want content URIs. For example, these XML elements specify two directories: + *

+ * <paths xmlns:android="http://schemas.android.com/apk/res/android">
+ *    <files-path name="my_images" path="images/"/>
+ *    <files-path name="my_docs" path="docs/"/>
+ * </paths>
+ * 
+ *

+ * Put the <paths> element and its children in an XML file in your project. + * For example, you can add them to a new file called res/xml/file_paths.xml. + * + * To link this file to the FileProvider, pass it to super() in the constructor for the + * FileProvider you defined above, add a <meta-data> element as a child of the <provider> + * element that defines the FileProvider. Set the <meta-data> element's + * "android:name" attribute to android.support.FILE_PROVIDER_PATHS. Set the + * element's "android:resource" attribute to @xml/file_paths (notice that you + * don't specify the .xml extension). For example: + *

+ * <provider
+ *    android:name="com.sample.MyFileProvider"
+ *    android:authorities="com.mydomain.fileprovider"
+ *    android:exported="false"
+ *    android:grantUriPermissions="true">
+ *    <meta-data
+ *        android:name="android.support.FILE_PROVIDER_PATHS"
+ *        android:resource="@xml/file_paths" />
+ * </provider>
+ * 
+ *

+ * Generating the Content URI for a File + *

+ * 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 ContentResolver#openFileDescriptor(Uri, String) + * ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}. + *

+ * For example, suppose your app is offering files to other apps with a FileProvider that has the + * authority com.mydomain.fileprovider. To get a content URI for the file + * default_image.jpg in the images/ subdirectory of your internal storage + * add the following code: + *

+ * File imagePath = new File(Context.getFilesDir(), "my_images");
+ * File newFile = new File(imagePath, "default_image.jpg");
+ * Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
+ * 
+ * As a result of the previous snippet, + * {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI + * content://com.mydomain.fileprovider/my_images/default_image.jpg. + *

+ * Granting Temporary Permissions to a URI + *

+ * To grant an access permission to a content URI returned from + * {@link #getUriForFile(Context, String, File) getUriForFile()}, you can either grant the + * permission to a specific package or include the permission in an intent, as shown in the + * following sections. + *

Grant Permission to a Specific Package

+ *

+ * Call the method + * {@link Context#grantUriPermission(String, Uri, int) + * Context.grantUriPermission(package, Uri, mode_flags)} for the content:// + * {@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 mode_flags 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. + *

+ *

Include the Permission in an Intent

+ *

+ * To allow the user to choose which app receives the intent, and the permission to access the + * content, do the following: + *

+ *
    + *
  1. + * Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}. + *
  2. + *
  3. + *

    + * 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. + *

    + *

    + * To support devices that run a version between Android 4.1 (API level 16) and Android 5.1 + * (API level 22) inclusive, create a {@link ClipData} object from the content + * URI, and set the access permissions on the ClipData object: + *

    + *
    + * shareContentIntent.setClipData(ClipData.newRawUri("", contentUri));
    + * shareContentIntent.addFlags(
    + *         Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    + * 
    + *
  4. + *
  5. + * Send the {@link Intent} to + * another app. Most often, you do this by calling + * {@link Activity#setResult(int, Intent) setResult()}. + *
  6. + *
+ *

+ * Permissions granted in an {@link Intent} remain in effect while the stack of the receiving + * {@link Activity} is active. When the stack finishes, the permissions are + * automatically removed. Permissions granted to one {@link Activity} in a client + * app are automatically extended to other components of that app. + *

+ * Serving a Content URI to Another App + *

+ * 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 Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()}, + * which sends an {@link Intent} to your app to start an {@link 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 Activity#setResult(int, Intent) setResult()}. + *

+ *

+ * You can also put the content URI in a {@link 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 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. + *

+ *

+ * Note: 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()}. + *

+ * More Information + *

+ * To learn more about FileProvider, see the Android training class + * Sharing Files Securely with + * URIs. + *

+ */ +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 String DISPLAYNAME_FIELD = "displayName"; + + private static final File DEVICE_ROOT = new File("/"); + + @GuardedBy("sCache") + private static final HashMap sCache = new HashMap<>(); + + // Do not use {@code mLocalPathStrategy} directly; access it via {@link #getLocalPathStrategy}. + @GuardedBy("this") + @Nullable private PathStrategy mLocalPathStrategy; + + private int mResourceId; + private String mAuthority; + + public FileProvider() { + mResourceId = 0; + } + + protected FileProvider(@XmlRes int resourceId) { + mResourceId = resourceId; + } + + /** + * 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. + */ + @SuppressWarnings("StringSplitter") + @Override + public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) { + super.attachInfo(context, info); + + // Check our security attributes + if (info.exported) { + throw new SecurityException("Provider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("Provider must grant uri permissions"); + } + + mAuthority = info.authority.split(";")[0]; + synchronized (sCache) { + sCache.remove(mAuthority); + } + } + + /** + * 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 + * content {@link Uri} for file paths defined in their <paths> + * 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 } element in your app's manifest. + * @param file A {@link File} pointing to the filename for which you want a + * content {@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, 0); + return strategy.getUriForFile(file); + } + + /** + * 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 + * content {@link Uri} for file paths defined in their <paths> + * 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 } element in your app's manifest. + * @param file A {@link File} pointing to the filename for which you want a + * content {@link Uri}. + * @param displayName The filename to be displayed. This can be used if the original filename + * is undesirable. + * @return A content URI for the file. + * @throws IllegalArgumentException When the given {@link File} is outside + * the paths supported by the provider. + */ + @SuppressLint("StreamFiles") + @NonNull + public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, + @NonNull File file, @NonNull String displayName) { + Uri uri = getUriForFile(context, authority, file); + return uri.buildUpon().appendQueryParameter(DISPLAYNAME_FIELD, displayName).build(); + } + + /** + * 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}: + *
    + *
  • {@link OpenableColumns#DISPLAY_NAME}
  • + *
  • {@link OpenableColumns#SIZE}
  • + *
+ * 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 selection parameter. The query method scans selection from left to + * right and iterates through selectionArgs, replacing the current "?" character in + * selection with the value at the current position in selectionArgs. The + * values are bound to selection 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. + * + */ + @NonNull + @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 = getLocalPathStrategy().getFileForUri(uri); + String displayName = uri.getQueryParameter(DISPLAYNAME_FIELD); + + 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++] = (displayName == null) ? file.getName() : displayName; + } 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 application/octet-stream. + */ + @Nullable + @Override + public String getType(@NonNull Uri uri) { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().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"; + } + + /** + * Unrestricted version of getType + * called, when caller does not have corresponding permissions + */ + //@Override + @SuppressWarnings("MissingOverride") + @Nullable + public String getTypeAnonymous(@NonNull Uri uri) { + 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, @NonNull 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, @NonNull 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 not 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 = getLocalPathStrategy().getFileForUri(uri); + return file.delete() ? 1 : 0; + } + + /** + * By default, FileProvider automatically returns the + * {@link ParcelFileDescriptor} for a file associated with a content:// + * {@link Uri}. To get the {@link ParcelFileDescriptor}, call + * {@link 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. + */ + @SuppressLint("UnknownNullness") // b/171012356 + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) + throws FileNotFoundException { + // ContentProvider has already checked granted permissions + final File file = getLocalPathStrategy().getFileForUri(uri); + final int fileMode = modeToMode(mode); + return ParcelFileDescriptor.open(file, fileMode); + } + + /** Return the local {@link PathStrategy}, creating it if necessary. */ + private PathStrategy getLocalPathStrategy() { + synchronized (this) { + if (mLocalPathStrategy == null) { + mLocalPathStrategy = getPathStrategy(getContext(), mAuthority, mResourceId); + } + + return mLocalPathStrategy; + } + } + + /** + * Return {@link PathStrategy} for given authority, either by parsing or + * returning from cache. + */ + private static PathStrategy getPathStrategy(Context context, String authority, int resourceId) { + PathStrategy strat; + synchronized (sCache) { + strat = sCache.get(authority); + if (strat == null) { + try { + strat = parsePathStrategy(context, authority, resourceId); + } 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; + } + + @VisibleForTesting + static XmlResourceParser getFileProviderPathsMetaData(Context context, String authority, + @Nullable ProviderInfo info, + int resourceId) { + if (info == null) { + throw new IllegalArgumentException( + "Couldn't find meta-data for provider with authority " + authority); + } + + if (info.metaData == null && resourceId != 0) { + info.metaData = new Bundle(1); + info.metaData.putInt(META_DATA_FILE_PROVIDER_PATHS, resourceId); + } + + 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"); + } + + return in; + } + + /** + * Parse and return {@link PathStrategy} for given authority as defined in + * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code }. + * + * @see #getPathStrategy(Context, String, int) + */ + private static PathStrategy parsePathStrategy(Context context, String authority, int resourceId) + throws IOException, XmlPullParserException { + final SimplePathStrategy strat = new SimplePathStrategy(authority); + + final ProviderInfo info = context.getPackageManager() + .resolveContentProvider(authority, PackageManager.GET_META_DATA); + final XmlResourceParser in = getFileProviderPathsMetaData(context, authority, info, + resourceId); + + 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 = Api21Impl.getExternalMediaDirs(context); + 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}. + *

+ * 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. + *

+ * 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 allowed list + * of filesystem roots. It will throw {@link SecurityException} if callers try + * accessing files outside the configured roots. + *

+ * 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 mRoots = new HashMap<>(); + + 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 mostSpecific = null; + for (Map.Entry 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; + } + + @RequiresApi(21) + static class Api21Impl { + private Api21Impl() { + // This class is not instantiable. + } + + @DoNotInline + static File[] getExternalMediaDirs(Context context) { + // Deprecated, otherwise this would belong on context as a public method. + return context.getExternalMediaDirs(); + } + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java index 814c9f8f92..7d4652f79d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java @@ -1,12 +1,23 @@ package org.joinmastodon.android.utils; +import static org.joinmastodon.android.model.FilterAction.HIDE; +import static org.joinmastodon.android.model.FilterAction.WARN; +import static org.joinmastodon.android.model.FilterContext.ACCOUNT; +import static org.joinmastodon.android.model.FilterContext.HOME; +import static org.joinmastodon.android.model.FilterContext.NOTIFICATIONS; +import static org.joinmastodon.android.model.FilterContext.PUBLIC; +import static org.joinmastodon.android.model.FilterContext.THREAD; + +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.AltTextFilter; import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.FilterAction; import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.Status; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Predicate; @@ -14,6 +25,7 @@ import java.util.stream.Stream; public class StatusFilterPredicate implements Predicate{ + private final List clientFilters; private final List filters; private final FilterContext context; private final FilterAction action; @@ -22,16 +34,17 @@ public class StatusFilterPredicate implements Predicate{ /** * @param context null makes the predicate pass automatically * @param action defines what the predicate should check: - * status should not be hidden or should not display with warning + * status should not be hidden or should not display with warning */ public StatusFilterPredicate(List filters, FilterContext context, FilterAction action){ this.filters = filters; this.context = context; this.action = action; + this.clientFilters = getClientFilters(); } public StatusFilterPredicate(List filters, FilterContext context){ - this(filters, context, FilterAction.HIDE); + this(filters, context, HIDE); } /** @@ -43,18 +56,27 @@ public StatusFilterPredicate(String accountID, FilterContext context, FilterActi filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList()); this.context = context; this.action = action; + this.clientFilters = getClientFilters(); + } + + private List getClientFilters() { + List filters = new ArrayList<>(); + if(!GlobalUserPreferences.showPostsWithoutAlt) { + filters.add(new AltTextFilter(WARN, HOME, PUBLIC, ACCOUNT, THREAD, NOTIFICATIONS)); + } + return filters; } /** * @param context null makes the predicate pass automatically */ public StatusFilterPredicate(String accountID, FilterContext context){ - this(accountID, context, FilterAction.HIDE); + this(accountID, context, HIDE); } /** * @return whether the status should be displayed without being hidden/warned about. - * will always return true if the context is null. + * will always return true if the context is null. * true = display this status, * false = filter this status */ @@ -74,9 +96,18 @@ public boolean test(Status status){ // only apply filters for given context .filter(filter -> filter.context.contains(context)) // treating filterAction = null (from filters list) as FilterAction.HIDE - .filter(filter -> filter.filterAction == null ? action == FilterAction.HIDE : filter.filterAction == action) + .filter(filter -> filter.filterAction == null ? action == HIDE : filter.filterAction == action) .findAny(); + //Apply client filters if no server filter is triggered + if (applyingFilter.isEmpty() && !clientFilters.isEmpty()) { + applyingFilter = clientFilters.stream() + .filter(filter -> filter.context.contains(context)) + .filter(filter -> filter.filterAction == null ? action == HIDE : filter.filterAction == action) + .filter(filter -> filter.matches(status)) + .findAny(); + } + this.applyingFilter = applyingFilter.orElse(null); return applyingFilter.isEmpty(); } diff --git a/mastodon/src/main/res/color/bookmark_icon.xml b/mastodon/src/main/res/color/bookmark_icon.xml index 442c8fbbc7..2b6f9dd7d7 100644 --- a/mastodon/src/main/res/color/bookmark_icon.xml +++ b/mastodon/src/main/res/color/bookmark_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/compose_button.xml b/mastodon/src/main/res/color/compose_button.xml index c8c71a1867..a261383f47 100644 --- a/mastodon/src/main/res/color/compose_button.xml +++ b/mastodon/src/main/res/color/compose_button.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/favorite_icon.xml b/mastodon/src/main/res/color/favorite_icon.xml index 201576cd78..a459076eb8 100644 --- a/mastodon/src/main/res/color/favorite_icon.xml +++ b/mastodon/src/main/res/color/favorite_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/like_icon.xml b/mastodon/src/main/res/color/like_icon.xml index 242d799861..e0346ce881 100644 --- a/mastodon/src/main/res/color/like_icon.xml +++ b/mastodon/src/main/res/color/like_icon.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml b/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml index f93eec7c88..db014251df 100644 --- a/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml +++ b/mastodon/src/main/res/color/m3_on_secondary_container_overlay.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_on_surface_alpha12.xml b/mastodon/src/main/res/color/m3_on_surface_alpha12.xml new file mode 100644 index 0000000000..371f861235 --- /dev/null +++ b/mastodon/src/main/res/color/m3_on_surface_alpha12.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/translate_icon.xml b/mastodon/src/main/res/color/translate_icon.xml new file mode 100644 index 0000000000..bb9ba30d6c --- /dev/null +++ b/mastodon/src/main/res/color/translate_icon.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml b/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml new file mode 100644 index 0000000000..bddf442913 --- /dev/null +++ b/mastodon/src/main/res/drawable-anydpi-v24/ic_ntf_logo.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml b/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml index bcac189176..8389120364 100644 --- a/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml +++ b/mastodon/src/main/res/drawable-anydpi-v26/ic_launcher_foreground.xml @@ -1,21 +1,25 @@ - - - - - - - - - + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="43.043" + android:viewportHeight="43.043"> + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png new file mode 100644 index 0000000000..75650e5170 Binary files /dev/null and b/mastodon/src/main/res/drawable-hdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png new file mode 100644 index 0000000000..4ab4d91588 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml index 0a4d6a75f0..19df2cdce4 100644 --- a/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml +++ b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml @@ -1,5 +1,21 @@ - - - + + + + + + diff --git a/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png new file mode 100644 index 0000000000..0fcae94422 Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png b/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png new file mode 100644 index 0000000000..f5497cbf31 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/ic_ntf_logo.png differ diff --git a/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml b/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml new file mode 100644 index 0000000000..d000a4700d --- /dev/null +++ b/mastodon/src/main/res/drawable-xxxhdpi/ic_fluent_task_list_ltr_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/bg_button_m3_filled.xml b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml index d3eacf6216..21be7cacdb 100644 --- a/mastodon/src/main/res/drawable/bg_button_m3_filled.xml +++ b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml @@ -14,7 +14,7 @@ - + diff --git a/mastodon/src/main/res/drawable/bg_note_edit.xml b/mastodon/src/main/res/drawable/bg_note_edit.xml new file mode 100644 index 0000000000..536ee0367a --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_note_edit.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_popup.xml b/mastodon/src/main/res/drawable/bg_popup.xml index b1adf556fa..238e360900 100644 --- a/mastodon/src/main/res/drawable/bg_popup.xml +++ b/mastodon/src/main/res/drawable/bg_popup.xml @@ -3,13 +3,13 @@ - + - + diff --git a/mastodon/src/main/res/drawable/bg_search_field.xml b/mastodon/src/main/res/drawable/bg_search_field.xml index 3347a9d6b4..02fc62d8c3 100644 --- a/mastodon/src/main/res/drawable/bg_search_field.xml +++ b/mastodon/src/main/res/drawable/bg_search_field.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_bot.xml b/mastodon/src/main/res/drawable/ic_bot.xml new file mode 100644 index 0000000000..9095124ed3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_bot.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml index 7d2eb9fe89..f7f922cdd6 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector.xml @@ -1,8 +1,8 @@ - - - + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml new file mode 100644 index 0000000000..64e6a9d69e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_24_selector_for_tabbar.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml new file mode 100644 index 0000000000..9a652f86bc --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_off_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml new file mode 100644 index 0000000000..149dcf7c92 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_off_28_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml new file mode 100644 index 0000000000..67d12759db --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_urgent_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml new file mode 100644 index 0000000000..1d39a69931 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_autofit_down_24_regular.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml new file mode 100644 index 0000000000..aa0de66855 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_circle_up_24_regular.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml index 0a3fb4e2eb..7700eb0c7e 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_forward_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml index f64be47594..18c63baef6 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_repeat_all_24_very_filled.xml @@ -3,21 +3,21 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - - + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml new file mode 100644 index 0000000000..9c86090e1d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_book_exclamation_mark_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml new file mode 100644 index 0000000000..e8e056b79b --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_16_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml new file mode 100644 index 0000000000..e182b6fee4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_20_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml new file mode 100644 index 0000000000..e05adcb6f5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_bot_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml new file mode 100644 index 0000000000..f488694067 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_camera_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml index 4757c70845..f9f5537c56 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_24_filled.xml @@ -1,3 +1,3 @@ - + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml index d65c346f9e..eb2b425802 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_multiple_24_regular_text.xml @@ -3,13 +3,13 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml new file mode 100644 index 0000000000..f0c00234f0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_settings_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml new file mode 100644 index 0000000000..3501bc6ad5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled_enabled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml new file mode 100644 index 0000000000..8e7826194f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml new file mode 100644 index 0000000000..fd88028966 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_comment_mention_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml new file mode 100644 index 0000000000..fef28319b1 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_custom_alert_latest_24_regular.xml @@ -0,0 +1,12 @@ + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml new file mode 100644 index 0000000000..246962e19f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_right_24_regular.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml new file mode 100644 index 0000000000..9ba137e447 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_double_tap_swipe_up_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml new file mode 100644 index 0000000000..b88fafb4b3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml index e1be468be7..7cd0b67578 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml new file mode 100644 index 0000000000..46f2eebbc6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_eye_tracking_on_24_regular.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml new file mode 100644 index 0000000000..dc4d0296a5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_lists_28_selector.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml new file mode 100644 index 0000000000..67619d7cf6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_notepad_20_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml new file mode 100644 index 0000000000..ccb3b275f7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_paint_brush_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml new file mode 100644 index 0000000000..c182f38028 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml new file mode 100644 index 0000000000..7d20e56076 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_phone_vibrate_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml new file mode 100644 index 0000000000..98638052c7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_save_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml new file mode 100644 index 0000000000..6618d92ddb --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_save_24_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml index c5a542eaa8..a26d4548f5 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_send_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml new file mode 100644 index 0000000000..3c8aaab1e9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shield_prohibited_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml new file mode 100644 index 0000000000..8ba6f262d4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml new file mode 100644 index 0000000000..486228e0e6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_2_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml new file mode 100644 index 0000000000..8cd5f52dc7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml new file mode 100644 index 0000000000..b45d213d39 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_speaker_mute_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml new file mode 100644 index 0000000000..381063145e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_translate_24_filled.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml index bab48ab1ea..d913c311fe 100644 --- a/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml +++ b/mastodon/src/main/res/drawable/ic_fluent_translate_24_regular.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml new file mode 100644 index 0000000000..98eeae2db2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_video_clip_24_regular.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_background.xml b/mastodon/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..ca3826a46c --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_foreground.xml b/mastodon/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..4bbbc045dd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml b/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..7de0ba5abb --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_ntf_logo.xml b/mastodon/src/main/res/drawable/ic_ntf_logo.xml deleted file mode 100644 index d7eb886b34..0000000000 --- a/mastodon/src/main/res/drawable/ic_ntf_logo.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/mastodon/src/main/res/drawable/ic_translate.xml b/mastodon/src/main/res/drawable/ic_translate.xml new file mode 100644 index 0000000000..19aaed81bd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/logo.xml b/mastodon/src/main/res/drawable/logo.xml index 3420a89f2a..aec5187b0d 100644 --- a/mastodon/src/main/res/drawable/logo.xml +++ b/mastodon/src/main/res/drawable/logo.xml @@ -1,34 +1,31 @@ + android:width="109.08dp" + android:height="18.02dp" + android:viewportWidth="109.08" + android:viewportHeight="18.02"> - diff --git a/mastodon/src/main/res/drawable/seekbar_video_player.xml b/mastodon/src/main/res/drawable/seekbar_video_player.xml index d3c7ce58db..685a6a9d38 100644 --- a/mastodon/src/main/res/drawable/seekbar_video_player.xml +++ b/mastodon/src/main/res/drawable/seekbar_video_player.xml @@ -2,7 +2,7 @@ - + diff --git a/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml index eb8bc3bc41..aef817f276 100644 --- a/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml +++ b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/compose_action.xml b/mastodon/src/main/res/layout/compose_action.xml index ab0d6fe4d7..441cbb0a6e 100644 --- a/mastodon/src/main/res/layout/compose_action.xml +++ b/mastodon/src/main/res/layout/compose_action.xml @@ -11,7 +11,7 @@ android:id="@+id/language_btn" style="@style/Widget.Mastodon.M3.Button.Text" android:layout_width="wrap_content" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:paddingStart="12dp" android:paddingEnd="12dp" android:drawableStart="@drawable/ic_fluent_local_language_16_regular" @@ -32,8 +32,19 @@ android:tint="?colorM3OnSurfaceVariant" android:contentDescription="@string/sk_schedule_or_draft" android:tooltipText="@string/sk_schedule_or_draft" + android:visibility="gone" tools:targetApi="o" /> + +