**! ! Warning**: Do not merge before testing every API call and database
read involving JSON !
**Gson** is obsolete and has been superseded by **Moshi**. But more
importantly, parsing Kotlin objects using Gson is _dangerous_ because
Gson uses Java serialization and is **not Kotlin-aware**. This has two
main consequences:
- Fields of non-null types may end up null at runtime. Parsing will
succeed, but the code may crash later with a `NullPointerException` when
trying to access a field member;
- Default values of constructor parameters are always ignored. When
absent, reference types will be null, booleans will be false and
integers will be zero.
On the other hand, Kotlin-aware parsers like **Moshi** or **Kotlin
Serialization** will validate at parsing time that all received fields
comply with the Kotlin contract and avoid errors at runtime, making apps
more stable and schema mismatches easier to detect (as long as logs are
accessible):
- Receiving a null value for a non-null type will generate a parsing
error;
- Optional types are declared explicitly by adding a default value. **A
missing value with no default value declaration will generate a parsing
error.**
Migrating the entity declarations from Gson to Moshi will make the code
more robust but is not an easy task because of the semantic differences.
With Gson, both nullable and optional fields are represented with a null
value. After converting to Moshi, some nullable entities can become
non-null with a default value (if they are optional and not nullable),
others can stay nullable with no default value (if they are mandatory
and nullable), and others can become **nullable with a default value of
null** (if they are optional _or_ nullable _or_ both). That third option
is the safest bet when it's not clear if a field is optional or not,
except for lists which can usually be declared as non-null with a
default value of an empty list (I have yet to see a nullable array type
in the Mastodon API).
Fields that are currently declared as non-null present another
challenge. In theory, they should remain as-is and everything will work
fine. In practice, **because Gson is not aware of nullable types at
all**, it's possible that some non-null fields currently hold a null
value in some cases but the app does not report any error because the
field is not accessed by Kotlin code in that scenario. After migrating
to Moshi however, parsing such a field will now fail early if a null
value or no value is received.
These fields will have to be identified by heavily testing the app and
looking for parsing errors (`JsonDataException`) and/or by going through
the Mastodon documentation. A default value needs to be added for
missing optional fields, and their type could optionally be changed to
nullable, depending on the case.
Gson is also currently used to serialize and deserialize objects to and
from the local database, which is also challenging because backwards
compatibility needs to be preserved. Fortunately, by default Gson omits
writing null fields, so a field of type `List<T>?` could be replaced
with a field of type `List<T>` with a default value of `emptyList()` and
reading back the old data should still work. However, nullable lists
that are written directly (not as a field of another object) will still
be serialized to JSON as `"null"` so the deserializing code must still
be handling null properly.
Finally, changing the database schema is out of scope for this pull
request, so database entities that also happen to be serialized with
Gson will keep their original types even if they could be made non-null
as an improvement.
In the end this is all for the best, because the app will be more
reliable and errors will be easier to detect by showing up earlier with
a clear error message. Not to mention the performance benefits of using
Moshi compared to Gson.
- Replace Gson reflection with Moshi Kotlin codegen to generate all
parsers at compile time.
- Replace custom `Rfc3339DateJsonAdapter` with the one provided by
moshi-adapters.
- Replace custom `JsonDeserializer` classes for Enum types with
`EnumJsonAdapter.create(T).withUnknownFallback()` from moshi-adapters to
support fallback values.
- Replace `GuardedBooleanAdapter` with the more generic `GuardedAdapter`
which works with any type. Any nullable field may now be annotated with
`@Guarded`.
- Remove Proguard rules related to Json entities. Each Json entity needs
to be annotated with `@JsonClass` with no exception, and adding this
annotation will ensure that R8/Proguard will handle the entities
properly.
- Replace some nullable Boolean fields with non-null Boolean fields with
a default value where possible.
- Replace some nullable list fields with non-null list fields with a
default value of `emptyList()` where possible.
- Update `TimelineDao` to perform all Json conversions internally using
`Converters` so no Gson or Moshi instance has to be passed to its
methods.
- ~~Create a custom `DraftAttachmentJsonAdapter` to serialize and
deserialize `DraftAttachment` which is a special entity that supports
more than one json name per field. A custom adapter is necessary because
there is not direct equivalent of `@SerializedName(alternate = [...])`
in Moshi.~~ Remove alternate names for some `DraftAttachment` fields
which were used as a workaround to deserialize local data in 2-years old
builds of Tusky.
- Update tests to make them work with Moshi.
- Simplify a few `equals()` implementations.
- Change a few functions to `val`s
- Turn `NetworkModule` into an `object` (since it contains no abstract
methods).
Please test the app thoroughly before merging. There may be some fields
currently declared as mandatory that are actually optional.
This pull request removes the remaining RxJava code and replaces it with
coroutine-equivalent implementations.
- Remove all duplicate methods in `MastodonApi`:
- Methods returning a RxJava `Single` have been replaced by suspending
methods returning a `NetworkResult` in order to be consistent with the
new code.
- _sync_/_async_ method variants are replaced with the _async_ version
only (suspending method), and `runBlocking{}` is used to make the async
variant synchronous.
- Create a custom coroutine-based implementation of `Single` for usage
in Java code where launching a coroutine is not possible. This class can
be deleted after remaining Java code has been converted to Kotlin.
- `NotificationsFragment.java` can subscribe to `EventHub` events by
calling the new lifecycle-aware `EventHub.subscribe()` method. This
allows using the `SharedFlow` as single source of truth for all events.
- Rx Autodispose is replaced by `lifecycleScope.launch()` which will
automatically cancel the coroutine when the Fragment view/Activity is
destroyed.
- Background work is launched in the existing injectable
`externalScope`, since using `GlobalScope` is discouraged.
`externalScope` has been changed to be a `@Singleton` and to use the
main dispatcher by default.
- Transform `ShareShortcutHelper` to an injectable utility class so it
can use the application `Context` and `externalScope` as provided
dependencies to launch a background coroutine.
- Implement a custom Glide extension method
`RequestBuilder.submitAsync()` to do the same thing as
`RequestBuilder.submit().get()` in a non-blocking way. This way there is
no need to switch to a background dispatcher and block a background
thread, and cancellation is supported out-of-the-box.
- An utility method `Fragment.updateRelativeTimePeriodically()` has been
added to remove duplicate logic in `TimelineFragment` and
`NotificationsFragment`, and the logic is now implemented using a simple
coroutine instead of `Observable.interval()`. Note that the periodic
update now happens between onStart and onStop instead of between
onResume and onPause, since the Fragment is not interactive but is still
visible in the started state.
- Rewrite `BottomSheetActivityTest` using coroutines tests.
- Remove all RxJava library dependencies.
builds upon work from #4082
Additionally fixes some deprecations and adds support for [predictive
back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture).
I also refactored how the activity transitions work because they are
closely related to predictive back. The awkward
`finishWithoutSlideOutAnimation` is gone, activities that have been
started with slide in will now automatically close with slide out.
To test predictive back you need an emulator or device with Sdk 34
(Android 14) and then enable it in the developer settings.
Predictive back requires the back action to be determined before it
actually occurs so the system can play the right predictive animation,
which made a few reorganisations necessary.
closes#4082closes#4005
unlocks a bunch of dependency upgrades that require sdk 34
---------
Co-authored-by: Goooler <wangzongler@gmail.com>
There are some new rules, I think they mostly make sense, except for the
max line length which I had to disable because we are over it in a lot
of places.
---------
Co-authored-by: Goooler <wangzongler@gmail.com>
Steps to reproduce: Open the dialog to set a catption on an image.
Rotate the screen.
<details>
<summary>Stacktrace</summary>
```
Exception java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(java.lang.CharSequence)' on a null object reference
at com.keylesspalace.tusky.components.compose.dialog.CaptionDialog.onCreateView (CaptionDialog.kt:61)
at androidx.fragment.app.Fragment.performCreateView (Fragment.java:3114)
at androidx.fragment.app.DialogFragment.performCreateView (DialogFragment.java:775)
at androidx.fragment.app.FragmentStateManager.createView (FragmentStateManager.java:557)
at androidx.fragment.app.FragmentStateManager.moveToExpectedState (FragmentStateManager.java:272)
at androidx.fragment.app.FragmentStore.moveToExpectedState (FragmentStore.java:114)
at androidx.fragment.app.FragmentManager.moveToState (FragmentManager.java:1455)
at androidx.fragment.app.FragmentManager.dispatchStateChange (FragmentManager.java:3034)
at androidx.fragment.app.FragmentManager.dispatchActivityCreated (FragmentManager.java:2952)
at androidx.fragment.app.FragmentController.dispatchActivityCreated (FragmentController.java:263)
at androidx.fragment.app.FragmentActivity.onStart (FragmentActivity.java:350)
at androidx.appcompat.app.AppCompatActivity.onStart (AppCompatActivity.java:251)
at android.app.Instrumentation.callActivityOnStart (Instrumentation.java:1543)
at android.app.Activity.performStart (Activity.java:8682)
at android.app.ActivityThread.handleStartActivity (ActivityThread.java:4219)
at android.app.servertransaction.TransactionExecutor.performLifecycleSequence (TransactionExecutor.java:221)
at android.app.servertransaction.TransactionExecutor.cycleToPath (TransactionExecutor.java:201)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState (TransactionExecutor.java:173)
at android.app.servertransaction.TransactionExecutor.execute (TransactionExecutor.java:97)
at android.app.ActivityThread$H.handleMessage (ActivityThread.java:2584)
at android.os.Handler.dispatchMessage (Handler.java:106)
at android.os.Looper.loopOnce (Looper.java:226)
at android.os.Looper.loop (Looper.java:313)
at android.app.ActivityThread.main (ActivityThread.java:8810)
at java.lang.reflect.Method.invoke
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:604)
at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:1067)
```
</details>
Restoring the saved caption after the view was created fixes the
problem.
Steps to reproduce:
1. install Gboard
(https://play.google.com/store/apps/details?id=com.google.android.inputmethod.latin)
2. open a direct link to any image in Firefox
3. long-press the image to get a "Copy Image" dialogue (and copy the
image)
4. compose a new post in Tusky
5. Gboard will suggest to paste the image from clipboard
6. paste image, see that when opening alt text editor, it is filled out
with this garbage string
Why is this bad? It's not when I just fix the alt text. But it breaks
every mechanism that is supposed to remind me of adding alt text.
It's hard to argue that this is within scope of Tusky but I also don't
see it getting fixed in Gboard, so here we go.
Fixes: #4063
Switching from an AlertDialog to only a DialogFragment.
I didn't get the AlertDialog to be sized correctly.
It also opens now directly with the right (full screen) size. When the
imageView fails to load (i.e. with an audio file) it will be hidden.
This changes the button layout somewhat.
One observation: The placeholder text "... visually impaired..." is not
quite right as a description for an audio file is not intended for the
visually impaired. But I couldn't think of a better text just yet.
![grafik](https://github.com/tuskyapp/Tusky/assets/1618905/fd49d5bd-b86c-4659-abb9-f1776cbb2a55)
Set the "System Design" as the default theme.
This ensures that the app's initial behaviour respect's the user's system-wide theme choice, while still allowing the user to adjust it later.
This is only done for new installs of Tusky. If the user is upgrading from a previous release and they did not have an explicit theme set then the dark theme is used, and the UX does not change.
Before, intent creation was scattered across multiple sites, with account switching logic in both `ComposeActivity` and `MainActivity`.
Now, intents are only created in `MainActivity` Companion, and account switching only occurs in `MainActivity`
Fixes#3695
To determine the earliest day to show in the calendar, take the current
date/time, add the minimum scheduled seconds buffer (which may roll the
date/time over to the next day), and then clamp to the start of that
day. So it's either today (if the current time + minimum scheduled
seconds is less than midnight) or it's tomorrow.
When displaying the calendar work around a misfeature in Material Date
Picker. It accepts UTC seconds-since-epoch, but does not convert it to
the local time for display.
While I'm here, show the selected day in the time picker's title.
Fixes https://github.com/tuskyapp/Tusky/issues/3916
Update to Kotlin 1.9.0 and migrate to newer language idioms.
- Remove unnecessary @OptIn for features migrated to mainstream
- Use `data object` where appropriate
- Use new enum `entries` property
Migrate to touchimageview from photoview, and adjust the touch logic to correctly handle single finger drag, two finger pinch/stretch, flings, taps, and swipes.
As before, the features are:
- Single tap, show/hide controls and media description
- Double tap, zoom in/out
- Single finger drag up/down, scale/translate image, dismiss if scrolled too far
- Single finger drag left/right
- When not zoomed, swipe to next image if multiple images present
- When zoomed, scroll to edge of image, then to next image if multiple images present
- Two finger pinch/zoom, zoom in/out on the image
Behaviour differences to previous code
1. Bug fix: The image can't get "stuck" when zoomed, and impossible to scroll
2. Bug fix: Pinching is not mis-interpreted as a fling, closing the image
3. Bug fix: The zoom state of images is not lost or misinterpreted when the user swipes through multiple images
4. Bug fix: Double-tap zooms all the way, instead of stopping
5. Tapping outside the image does not dismiss it, controls and description show/hide
Fixes https://github.com/tuskyapp/Tusky/issues/3562, https://github.com/tuskyapp/Tusky/issues/2297
GBoard and other IME's support pasting images, which are converted to attachments.
Sometimes these have labels that describe the image. If present, set it as the default alt-text.
Fixes#3799
When the user is closing the compose view,
if it's new and empty, don't show a prompt.
if it's an existing draft and now empty, ask if the user wants to delete it or continue editing. I don't think there is much value in saving an empty draft.
---------
Fix a bug where the active account can be overwritten.
1. Have two accounts logged in to Tusky, A and B
2. Open Tusky as account A
3. Send a DM to account B (doesn't have to be a DM, just anything that creates a notification for account B)
4. Restart Tusky so the Android notification for the DM is displayed immediately. You are still acting as account A.
5. Drag down the Android notification, you should see two options, "Quick reply" and "Compose"
6. Tap "Compose"
7. ComposeActivity will start. You are now acting as account B. Compose and send a reply
8. You'll be returned to the "Home" tab.
The UI will show you are still account A (i.e., it's account A's avatar at the top of the screen, if you have the "Show username in toolbars" option turned on it will be account A's username in the toolbar).
But you are now seeing the home timeline for account B.
Fix this.
ComposeViewModel
- Do not rely on the active account in sendStatus(), receive the account ID as a parameter
ComposeActivity
- Use either the account ID from the intent, or the current active account. **Do not** change the active account
- Pass the account ID to use in the sendStatus() call
Previous code would discard the image alt-text if the user finished writing the text before the image had finished uploading.
This code ensures the text is set after the image has completed uploading.
* 3434: Make description dialog (text field) more usable
* 3434: Close dialog on back button
* 3434: Use a TextInputLayout
* 3434: Adapt German plurals text
* 3434: Remove unused id
* 3434: Disable counter officially
* Move compose.* tests to own namespace
* Ignore "@instance..." part of username when computing status length
In a status with a mention ("@foo@example.org") only the "@foo" part should
be included in the calculated status length. It wasn't, so the app was
prevening people from posting statuses that should have been allowed.
Fix this.
- Lift the length calculation code in to a separate static function (easier
and faster to test)
- Add a `MentionSpan` type, to reuse existing code for detecting mentions
- Fix a bug in `FakeSpannable.getSpans()` (it was returning the outer type,
not the wrapped inner span)
- Add additional fast tests
The tests made sense under the `components.compose.ComposeActivity` package,
so I also created that and moved the existing ComposeActivity tests there.
Fixes https://github.com/tuskyapp/Tusky/issues/3339
* Static import assertEquals
* Replace DefaultTextWatcher with extensions in core-ktx
* Fix positiveButton.isEnabled
* editable!! for highlightSpans
* Fix style
* Put noteWatcher back
* Kotlin 1.8.10
https://github.com/JetBrains/kotlin/releases/tag/v1.8.10
* Migrate onActivityCreated to onViewCreated
* More final modifiers
* Java Cleanups
* Kotlin cleanups
* More final modifiers
* Const value TOOLBAR_HIDE_DELAY_MS
* Revert
* Add support for updating media description and focus point when editing statuses
* Don't publish description/focus point updates via the standard api when editing a published post
MIME type detection for files based on extensions (the `getType()` method)
returns incorrect results from DownloadProvider and FileProvider for (at
least) .m4a files.
Investigation details are in https://github.com/tuskyapp/Tusky/issues/3189
Be safe, and use `MediaMetadataRetriever` to sniff the content of the files
to determine the correct type.
Fixes https://github.com/tuskyapp/Tusky/issues/3189
* Fix saving changes to statuses when editing
With the previous code backing out of a status editing operation where changes
had been made (whether it was editing an existing status, a scheduled status,
or a draft) would prompt the user to save the changes as a new draft.
See https://github.com/tuskyapp/Tusky/issues/2704 and
https://github.com/tuskyapp/Tusky/issues/2705 for more detail.
The fix:
- Create an enum to represent the four different kinds of edits that can
happen
- Editing a new status (i.e., composing it for the first time)
- Editing a posted status
- Editing a draft
- Editing a scheduled status
- Store this in ComposeOptions, and set it appropriately everywhere
ComposeOptions is created.
- Check the edit kind when backing out of ComposeActivity, and use this to
show one of three different dialogs as appropriate so the user can:
- Save as new draft or discard changes
- Continue editing or discard changes
- Update existing draft or discard changes
Also fix ComposeViewModel.didChange(), which erroneously reported false if the
old text started with the new text (e.g., if the old text was "hello, world"
and it was edited to "hello", didChange() would not consider that to be a
change).
Fixes https://github.com/tuskyapp/Tusky/issues/2704,
https://github.com/tuskyapp/Tusky/issues/2705
* Use orEmpty extension function
* Fix off-by-one error in HttpHeaderLink
Link headers with multiple URLs with multiple parameters were being parsed
incorrectly.
Detected by adding unit tests ahead of converting to Kotlin.
* Convert util/HttpHeaderLink from Java to Kotlin
* Convert util/ThemeUtils from Java to Kotlin
* Convert util/TimestampUtils from Java to Kotlin
* Add tests for PairedList
* Convert util/PairedList from Java to Kotlin
* Implement feedback from PR
* Relicense as GPL
* Add post editing capability
* Don't try to reprocess already uploaded attachments.
Fixes editing posts with existing media
* Don't mark post edits as modified until editing occurs
* Disable UI for things that can't be edited when editing a post
* Finally convert SFragment to kotlin
* Use api endpoint for fetching status source for editing
* Apply review feedback
* issue 2890: Show a warning icon if media description is missing
* issue 2890: Remove disturbing additional signs
* issue 2890: Add another icon; use a snackbar; change wording; use orange as color
* issue 2890: Remove now unneeded new resource
* issue 2890: Use a toast (also) to avoid elevation problems
* issue 2890: Use snackbar with elevation again; refactor a bit