Compare commits

..

3 Commits

Author SHA1 Message Date
MarieJ
7f3ab1e4a6 WIP 2024-01-18 18:32:47 +01:00
MarieJ
0fe2c54940 Merge master 2024-01-11 19:55:00 +01:00
MarieJ
86b0fe1c60 Started DMs 2024-01-11 19:32:50 +01:00
261 changed files with 2900 additions and 12249 deletions
.gitlab-ci.yml.gitmodulesREADME.md
app
build.gradle
schemas/org.pixeldroid.app.utils.db.AppDatabase
src/main
AndroidManifest.xml
java/org/pixeldroid/app
LoginActivity.ktMainActivity.kt
directMessages
directmessages
login
main
postCreation
posts
profile
settings
stories
utils
res/drawable

@ -126,7 +126,7 @@ fdroid build:
- .gradle
script:
# Put the correct versionName and versionCode in the .fdroid.yml
- sed -e "s/\${versionName}/$(./gradlew -q printVersionName)/" -e "s/\${versionCode}/$(./gradlew -q printVersionCode)/" .fdroid.yml.template > .fdroid.yml
- sed -e "s/\${versionName}/$(grep "versionName " app/build.gradle | awk '{print $2}' | tr -d \")$(grep "versionCode" app/build.gradle -m 1 | awk '{print $2}')/" -e "s/\${versionCode}/$(grep "versionCode" app/build.gradle -m 1 | awk '{print $2}')/" .fdroid.yml.template > .fdroid.yml
- rm .fdroid.yml.template
# each `fdroid build --on-server` run expects sudo, then uninstalls it
- set -x

2
.gitmodules vendored

@ -3,4 +3,4 @@
url = https://gitlab.com/artectrex/scrambler.git
[submodule "pixel_common"]
path = pixel_common
url = https://gitlab.shinice.net/pixeldroid/pixel_common.git
url = git@gitlab.shinice.net:pixeldroid/pixel_common.git

@ -9,16 +9,13 @@ Free (as in freedom) Android client for Pixelfed, the federated image sharing pl
<img src="https://pixeldroid.org/badge-fdroid.png" alt="Get it on F-Droid" width="206">
</a>
Come talk to us on Matrix, at <a href="https://matrix.to/#/#pixeldroid:gnugen.ch">#pixeldroid:gnugen.ch</a> !
## 🔧 Compiling the code yourself
If you want to try out PixelDroid on your own device, you can compile the source code yourself. To do that you can install [Android Studio](https://developer.android.com/studio/).
## 🎨 Art attribution
Our mascot was commissioned using funds from NLnet. The original file is `pixeldroid_mascot.svg` and it is adapted to work as an Android Drawable. This work is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/) (CC BY-SA).
Various works have been used from the pixelfed branding repository ( https://github.com/pixelfed/brand-assets ).
Various works have been used from the pixelfed branding repository ( https://github.com/pixelfed/brand-assets ). In addition, a drawing of a red panda is used for some error messages ( https://thenounproject.com/search/?q=red+panda&i=2877785 ).
## 🤝 Contribute
If you want to contribute, you can check out [CONTRIBUTING.md](CONTRIBUTING.md) and/or [TRANSLATION.md](TRANSLATION.md)

@ -1,33 +1,15 @@
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("com.android.application")
id("com.google.dagger.hilt.android")
id("kotlin-android")
id("jacoco")
id("kotlin-parcelize")
id("com.google.devtools.ksp")
}
// Map for the version code that gives each ABI a value.
ext.abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, x86: 3, x86_64: 4]
//Different version codes per architecture (for F-Droid support)
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI))
if (baseAbiVersionCode != null) {
output.versionCodeOverride = (100 * project.android.defaultConfig.versionCode) + baseAbiVersionCode
} else {
output.versionCodeOverride = 100 * project.android.defaultConfig.versionCode
}
}
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'jacoco'
apply plugin: "kotlin-parcelize"
apply plugin: 'com.google.devtools.ksp'
android {
namespace 'org.pixeldroid.app'
compileSdk 35
compileSdk 34
compileOptions {
coreLibraryDesugaringEnabled true
@ -45,18 +27,14 @@ android {
}
defaultConfig {
minSdkVersion 23
targetSdkVersion 35
versionCode 39
versionCode 26
targetSdkVersion 34
versionName "1.0.beta" + versionCode
//TODO add resConfigs("en", "fr", "ja",...) ?
testInstrumentationRunner "org.pixeldroid.app.testUtility.TestRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
sourceSets {
main.java.srcDirs += 'src/main/java'
@ -99,30 +77,6 @@ android {
proguardFiles 'proguard-rules.pro'
}
}
splits {
// Configures multiple APKs based on ABI.
abi {
// Enables building multiple APKs per ABI.
enable true
// By default all ABIs are included, so use reset() and include to specify that we only
// want APKs for "x86", "x86_64", "arm64-v8a" and "armeabi-v7a".
// Resets the list of ABIs for Gradle to create APKs for to none.
reset()
// Specifies a list of ABIs for Gradle to create APKs for.
//noinspection ChromeOsAbiSupport
include project.ext.abiCodes.keySet() as String[]
// Specifies that we don't want to also generate a universal APK that includes all ABIs.
universalApk false
}
}
/**
* Make a string with the application_id (available in xml etc)
*/
@ -166,56 +120,43 @@ android {
}
}
tasks.register('printVersionName') {
doLast {
println android.defaultConfig.versionName
}
}
tasks.register('printVersionCode') {
doLast {
println android.defaultConfig.versionCode
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.hilt:hilt-common:1.2.0'
implementation 'androidx.hilt:hilt-work:1.2.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
/**
* AndroidX dependencies:
*/
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.8.4'
implementation 'androidx.navigation:navigation-ui-ktx:2.8.4'
implementation "androidx.browser:browser:1.8.0"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
implementation "androidx.browser:browser:1.7.0"
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.8.4'
implementation 'androidx.navigation:navigation-ui-ktx:2.8.4'
implementation 'androidx.paging:paging-runtime-ktx:3.3.4'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
implementation "androidx.lifecycle:lifecycle-common-java8:2.8.7"
implementation "androidx.annotation:annotation:1.9.1"
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2'
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
implementation "androidx.lifecycle:lifecycle-common-java8:2.6.2"
implementation "androidx.annotation:annotation:1.7.1"
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.activity:activity-ktx:1.9.3"
implementation 'androidx.fragment:fragment-ktx:1.8.5'
implementation 'androidx.work:work-runtime-ktx:2.10.0'
implementation 'androidx.media2:media2-widget:1.3.0'
implementation 'androidx.media2:media2-player:1.3.0'
implementation "androidx.activity:activity-ktx:1.8.2"
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.work:work-runtime-ktx:2.9.0'
implementation 'androidx.media2:media2-widget:1.2.1'
implementation 'androidx.media2:media2-player:1.2.1'
// Use the most recent version of CameraX
def cameraX_version = '1.4.0'
def cameraX_version = '1.3.1'
implementation "androidx.camera:camera-core:$cameraX_version"
implementation "androidx.camera:camera-camera2:$cameraX_version"
// CameraX Lifecycle library
@ -237,27 +178,21 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
//Interactive tutorial
implementation 'com.getkeepsafe.taptargetview:taptargetview:1.14.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.android.material:material:1.11.0'
//Dagger (dependency injection)
implementation 'com.google.dagger:dagger:2.52'
ksp 'com.google.dagger:dagger-compiler:2.52'
implementation 'com.google.dagger:dagger:2.48'
ksp 'com.google.dagger:dagger-compiler:2.48'
implementation('com.google.dagger:hilt-android:2.52')
ksp 'com.google.dagger:hilt-compiler:2.52'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.11.0'
implementation 'io.reactivex.rxjava3:rxjava:3.1.9'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.retrofit2:adapter-rxjava3:2.9.0'
implementation 'io.reactivex.rxjava3:rxjava:3.1.6'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'com.github.connyduck:sparkbutton:4.1.0'
implementation 'org.pixeldroid.pixeldroid:android-media-editor:4.3'
implementation 'org.pixeldroid.pixeldroid:android-media-editor:1.5'
implementation project(path: ':scrambler')
implementation project(path: ':pixel_common')
@ -265,20 +200,26 @@ dependencies {
exclude group: "com.android.support"
}
implementation 'com.github.bumptech.glide:okhttp3-integration:4.16.0'
implementation('com.github.bumptech.glide:recyclerview-integration:4.16.0') {
implementation 'com.github.bumptech.glide:okhttp3-integration:4.14.2'
implementation('com.github.bumptech.glide:recyclerview-integration:4.14.2') {
// Excludes the support library because it's already included by Glide.
transitive = false
}
implementation 'com.github.bumptech.glide:annotations:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
ksp 'com.github.bumptech.glide:ksp:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
ksp 'com.github.bumptech.glide:ksp:4.14.2'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.mikepenz:materialdrawer:9.0.2'
implementation 'com.mikepenz:materialdrawer:9.0.1'
// Add for NavController support
implementation 'com.mikepenz:materialdrawer-nav:9.0.2'
implementation 'com.mikepenz:materialdrawer-nav:9.0.1'
//iconics
implementation 'com.mikepenz:iconics-core:5.4.0'
implementation 'com.mikepenz:materialdrawer-iconics:9.0.1'
implementation 'com.mikepenz:iconics-views:5.4.0'
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation 'com.github.ligi:tracedroid:4.1'
@ -294,36 +235,33 @@ dependencies {
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
androidTestUtil 'com.linkedin.testbutler:test-butler-app:2.2.1'
androidTestImplementation 'androidx.work:work-testing:2.10.0'
testImplementation 'org.wiremock:wiremock:3.9.1'
androidTestImplementation 'androidx.work:work-testing:2.9.0'
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.34.0'
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'junit:junit:4.13.2'
testImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
}
tasks.withType(Test).configureEach {
jacoco.includeNoLocationClasses = true
jacoco.excludes = ['jdk.internal.*']
}
tasks.register('jacocoTestReport', JacocoReport) {
dependsOn['connectedStagingAndroidTest', 'testStagingUnitTest', 'createStagingCoverageReport']
task jacocoTestReport(type: JacocoReport, dependsOn: ['connectedStagingAndroidTest', 'testStagingUnitTest', 'createStagingCoverageReport']) {
reports {
xml.required = true

@ -1,991 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "5ee0e8fbaef28650cbea6670e24e08bb",
"entities": [
{
"tableName": "instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
"fields": [
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "maxStatusChars",
"columnName": "maxStatusChars",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxPhotoSize",
"columnName": "maxPhotoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxVideoSize",
"columnName": "maxVideoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumLimit",
"columnName": "albumLimit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoEnabled",
"columnName": "videoEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pixelfed",
"columnName": "pixelfed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uri"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "display_name",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar_static",
"columnName": "avatar_static",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refreshToken",
"columnName": "refreshToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_users_instance_uri",
"unique": false,
"columnNames": [
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
}
],
"foreignKeys": [
{
"table": "instances",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"instance_uri"
],
"referencedColumns": [
"uri"
]
}
]
},
{
"tableName": "homePosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_homePosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "publicPosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_publicPosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_notifications_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "tabsChecked",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `tab` TEXT NOT NULL, `checked` INTEGER NOT NULL, `filter` TEXT, PRIMARY KEY(`index`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tab",
"columnName": "tab",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"index",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_tabsChecked_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tabsChecked_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `unread` INTEGER, `accounts` TEXT, `last_status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "last_status",
"columnName": "last_status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessages_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessages_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessagesThreads",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `hidden` INTEGER, `isAuthor` INTEGER, `type` TEXT, `text` TEXT, `media` TEXT, `carousel` TEXT, `created_at` TEXT, `timeAgo` TEXT, `reportId` TEXT, `conversationsId` TEXT NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `conversationsId`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isAuthor",
"columnName": "isAuthor",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media",
"columnName": "media",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "carousel",
"columnName": "carousel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timeAgo",
"columnName": "timeAgo",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reportId",
"columnName": "reportId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "conversationsId",
"columnName": "conversationsId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"conversationsId",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessagesThreads_user_id_instance_uri_conversationsId",
"unique": false,
"columnNames": [
"user_id",
"instance_uri",
"conversationsId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessagesThreads_user_id_instance_uri_conversationsId` ON `${TABLE_NAME}` (`user_id`, `instance_uri`, `conversationsId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ee0e8fbaef28650cbea6670e24e08bb')"
]
}
}

@ -1,710 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "365491e03fadf81e596b11716640518c",
"entities": [
{
"tableName": "instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
"fields": [
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "maxStatusChars",
"columnName": "maxStatusChars",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxPhotoSize",
"columnName": "maxPhotoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxVideoSize",
"columnName": "maxVideoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumLimit",
"columnName": "albumLimit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoEnabled",
"columnName": "videoEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pixelfed",
"columnName": "pixelfed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uri"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "display_name",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar_static",
"columnName": "avatar_static",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refreshToken",
"columnName": "refreshToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_users_instance_uri",
"unique": false,
"columnNames": [
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
}
],
"foreignKeys": [
{
"table": "instances",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"instance_uri"
],
"referencedColumns": [
"uri"
]
}
]
},
{
"tableName": "homePosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_homePosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "publicPosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_publicPosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_notifications_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '365491e03fadf81e596b11716640518c')"
]
}
}

@ -1,781 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "cd0310b10eff0e3e961b3cd7a8172b81",
"entities": [
{
"tableName": "instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
"fields": [
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "maxStatusChars",
"columnName": "maxStatusChars",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxPhotoSize",
"columnName": "maxPhotoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxVideoSize",
"columnName": "maxVideoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumLimit",
"columnName": "albumLimit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoEnabled",
"columnName": "videoEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pixelfed",
"columnName": "pixelfed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uri"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "display_name",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar_static",
"columnName": "avatar_static",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refreshToken",
"columnName": "refreshToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_users_instance_uri",
"unique": false,
"columnNames": [
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
}
],
"foreignKeys": [
{
"table": "instances",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"instance_uri"
],
"referencedColumns": [
"uri"
]
}
]
},
{
"tableName": "homePosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_homePosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "publicPosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_publicPosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_notifications_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "tabsChecked",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `tab` TEXT NOT NULL, `checked` INTEGER NOT NULL, PRIMARY KEY(`index`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tab",
"columnName": "tab",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"index",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_tabsChecked_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tabsChecked_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd0310b10eff0e3e961b3cd7a8172b81')"
]
}
}

@ -1,985 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "37b7f1d842d148e1d117ac9caae8fb51",
"entities": [
{
"tableName": "instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
"fields": [
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "maxStatusChars",
"columnName": "maxStatusChars",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxPhotoSize",
"columnName": "maxPhotoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxVideoSize",
"columnName": "maxVideoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumLimit",
"columnName": "albumLimit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoEnabled",
"columnName": "videoEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pixelfed",
"columnName": "pixelfed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uri"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "display_name",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar_static",
"columnName": "avatar_static",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refreshToken",
"columnName": "refreshToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_users_instance_uri",
"unique": false,
"columnNames": [
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
}
],
"foreignKeys": [
{
"table": "instances",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"instance_uri"
],
"referencedColumns": [
"uri"
]
}
]
},
{
"tableName": "homePosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_homePosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "publicPosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_publicPosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_notifications_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "tabsChecked",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `tab` TEXT NOT NULL, `checked` INTEGER NOT NULL, PRIMARY KEY(`index`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tab",
"columnName": "tab",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"index",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_tabsChecked_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tabsChecked_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `unread` INTEGER, `accounts` TEXT, `last_status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "last_status",
"columnName": "last_status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessages_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessages_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessagesThreads",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `hidden` INTEGER, `isAuthor` INTEGER, `type` TEXT, `text` TEXT, `media` TEXT, `carousel` TEXT, `created_at` TEXT, `timeAgo` TEXT, `reportId` TEXT, `conversationsId` TEXT NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `conversationsId`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isAuthor",
"columnName": "isAuthor",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media",
"columnName": "media",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "carousel",
"columnName": "carousel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timeAgo",
"columnName": "timeAgo",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reportId",
"columnName": "reportId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "conversationsId",
"columnName": "conversationsId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"conversationsId",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessagesThreads_user_id_instance_uri_conversationsId",
"unique": false,
"columnNames": [
"user_id",
"instance_uri",
"conversationsId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessagesThreads_user_id_instance_uri_conversationsId` ON `${TABLE_NAME}` (`user_id`, `instance_uri`, `conversationsId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37b7f1d842d148e1d117ac9caae8fb51')"
]
}
}

@ -1,991 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "5ee0e8fbaef28650cbea6670e24e08bb",
"entities": [
{
"tableName": "instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
"fields": [
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "maxStatusChars",
"columnName": "maxStatusChars",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxPhotoSize",
"columnName": "maxPhotoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxVideoSize",
"columnName": "maxVideoSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumLimit",
"columnName": "albumLimit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoEnabled",
"columnName": "videoEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pixelfed",
"columnName": "pixelfed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uri"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "display_name",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar_static",
"columnName": "avatar_static",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refreshToken",
"columnName": "refreshToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_users_instance_uri",
"unique": false,
"columnNames": [
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
}
],
"foreignKeys": [
{
"table": "instances",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"instance_uri"
],
"referencedColumns": [
"uri"
]
}
]
},
{
"tableName": "homePosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_homePosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "publicPosts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uri",
"columnName": "uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "spoiler_text",
"columnName": "spoiler_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media_attachments",
"columnName": "media_attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogs_count",
"columnName": "reblogs_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "favourites_count",
"columnName": "favourites_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "replies_count",
"columnName": "replies_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_id",
"columnName": "in_reply_to_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "in_reply_to_account",
"columnName": "in_reply_to_account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblog",
"columnName": "reblog",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_publicPosts_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "account",
"columnName": "account",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_notifications_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "tabsChecked",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `tab` TEXT NOT NULL, `checked` INTEGER NOT NULL, `filter` TEXT, PRIMARY KEY(`index`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tab",
"columnName": "tab",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"index",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_tabsChecked_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tabsChecked_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `unread` INTEGER, `accounts` TEXT, `last_status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "last_status",
"columnName": "last_status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessages_user_id_instance_uri",
"unique": false,
"columnNames": [
"user_id",
"instance_uri"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessages_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
},
{
"tableName": "directMessagesThreads",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `hidden` INTEGER, `isAuthor` INTEGER, `type` TEXT, `text` TEXT, `media` TEXT, `carousel` TEXT, `created_at` TEXT, `timeAgo` TEXT, `reportId` TEXT, `conversationsId` TEXT NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `conversationsId`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "hidden",
"columnName": "hidden",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isAuthor",
"columnName": "isAuthor",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "media",
"columnName": "media",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "carousel",
"columnName": "carousel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created_at",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timeAgo",
"columnName": "timeAgo",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reportId",
"columnName": "reportId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "conversationsId",
"columnName": "conversationsId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user_id",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instance_uri",
"columnName": "instance_uri",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"conversationsId",
"user_id",
"instance_uri"
]
},
"indices": [
{
"name": "index_directMessagesThreads_user_id_instance_uri_conversationsId",
"unique": false,
"columnNames": [
"user_id",
"instance_uri",
"conversationsId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_directMessagesThreads_user_id_instance_uri_conversationsId` ON `${TABLE_NAME}` (`user_id`, `instance_uri`, `conversationsId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"user_id",
"instance_uri"
],
"referencedColumns": [
"user_id",
"instance_uri"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ee0e8fbaef28650cbea6670e24e08bb')"
]
}
}

@ -23,9 +23,14 @@
android:name=".utils.PixelDroidApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/BaseAppTheme">
<activity
android:name=".directMessages.ConversationsActivity"
android:exported="false" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@ -38,22 +43,32 @@
<activity
android:name=".posts.AlbumActivity"
android:exported="false"
android:theme="@style/TransparentAlbumActivity"/>
android:theme="@style/AppTheme.ActionBar.Transparent" />
<activity
android:name=".profile.EditProfileActivity"
android:exported="false"
android:theme="@style/BaseAppTheme" />
<activity
android:name=".posts.MediaViewerActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="false"
android:theme="@style/BaseAppTheme" />
<activity android:name=".postCreation.camera.CameraActivity"
android:theme="@style/BaseAppTheme"/>
android:theme="@style/BaseAppTheme.NoActionBar" />
<activity android:name=".postCreation.camera.CameraActivity" />
<activity
android:name=".postCreation.camera.CameraActivityShortcut"
android:exported="true"
android:parentActivityName=".MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MainActivity" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
<activity
android:name=".posts.ReportActivity"
android:theme="@style/BaseAppTheme" />
android:screenOrientation="sensorPortrait"
android:theme="@style/BaseAppTheme"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".stories.StoriesActivity" />
@ -74,29 +89,39 @@
</activity>
<activity
android:name=".profile.FollowsActivity"
android:theme="@style/BaseAppTheme" />
android:theme="@style/BaseAppTheme"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity"
android:theme="@style/BaseAppTheme" />
android:theme="@style/BaseAppTheme"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".posts.PostActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity"
android:theme="@style/BaseAppTheme" />
<activity
android:name=".profile.ProfileActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity"
android:theme="@style/BaseAppTheme"/>
<activity android:name=".profile.CollectionActivity"
android:theme="@style/BaseAppTheme"/>
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings2"
android:parentActivityName=".main.MainActivity"
android:parentActivityName=".MainActivity"
android:theme="@style/BaseAppTheme" />
<activity
android:name=".main.MainActivity"
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustPan">
android:windowSoftInputMode="adjustPan"
tools:ignore="LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -106,14 +131,17 @@
<meta-data
android:name="android.app.default_searchable"
android:value="org.pixeldroid.app.searchDiscover.SearchActivity" />
<meta-data android:name="android.app.shortcuts"
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".login.LoginActivity"
android:name=".LoginActivity"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/BaseAppTheme"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
tools:ignore="LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -129,7 +157,9 @@
android:name=".searchDiscover.SearchActivity"
android:exported="true"
android:theme="@style/BaseAppTheme"
android:launchMode="singleTop">
android:launchMode="singleTop"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
@ -141,12 +171,6 @@
<activity android:name=".searchDiscover.TrendingActivity"
android:theme="@style/BaseAppTheme" />
<activity android:name=".directmessages.ConversationActivity"
android:theme="@style/BaseAppTheme"
android:windowSoftInputMode="adjustPan" />
<activity android:name="org.pixeldroid.app.directmessages.DirectMessagesActivity"
android:theme="@style/BaseAppTheme" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -156,16 +180,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

@ -0,0 +1,345 @@
package org.pixeldroid.app
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.gson.Gson
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.ActivityLoginBinding
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Application
import org.pixeldroid.app.utils.api.objects.Instance
import org.pixeldroid.app.utils.api.objects.NodeInfo
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.storeInstance
import org.pixeldroid.app.utils.hasInternet
import org.pixeldroid.app.utils.normalizeDomain
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
import org.pixeldroid.app.utils.openUrl
import org.pixeldroid.app.utils.validDomain
/**
Overview of the flow of the login process: (boxes are requests done in parallel,
since they do not depend on each other)
_________________________________
|[PixelfedAPI.registerApplication]|
|[PixelfedAPI.wellKnownNodeInfo] |
̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅
+----> [PixelfedAPI.nodeInfoSchema] (and then [PixelfedAPI.instance] if needed)
+----> [promptOAuth]
+----> [PixelfedAPI.obtainToken]
+----> [PixelfedAPI.verifyCredentials]
*/
class LoginActivity : BaseActivity() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private lateinit var oauthScheme: String
private lateinit var appName: String
private lateinit var preferences: SharedPreferences
private lateinit var pixelfedAPI: PixelfedAPI
private var inputVisibility: Int = View.GONE
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
loadingAnimation(true)
appName = getString(R.string.app_name)
oauthScheme = getString(R.string.auth_scheme)
preferences = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
if (hasInternet(applicationContext)) {
binding.connectInstanceButton.setOnClickListener {
registerAppToServer(normalizeDomain(binding.editText.text.toString()))
}
binding.whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() }
inputVisibility = View.VISIBLE
} else {
binding.loginActivityConnectionRequired.visibility = View.VISIBLE
binding.loginActivityConnectionRequiredButton.setOnClickListener {
finish()
startActivity(intent)
}
}
loadingAnimation(false)
}
override fun onStart() {
super.onStart()
val url: Uri? = intent.data
//Check if the activity was started after the authentication
if (url == null || !url.toString().startsWith("$oauthScheme://$PACKAGE_ID")) return
loadingAnimation(true)
val code = url.getQueryParameter("code")
authenticate(code)
}
override fun onStop() {
super.onStop()
loadingAnimation(false)
}
private fun whatsAnInstance() {
MaterialAlertDialogBuilder(this)
.setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
.setPositiveButton(android.R.string.ok) { _, _ -> }
// Create the AlertDialog
.show()
}
private fun hideKeyboard() {
val view = currentFocus
if (view != null) {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
view.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
}
}
private fun registerAppToServer(normalizedDomain: String) {
if(!validDomain(normalizedDomain)) return failedRegistration(getString(R.string.invalid_domain))
hideKeyboard()
loadingAnimation(true)
pixelfedAPI = PixelfedAPI.createFromUrl(normalizedDomain)
lifecycleScope.launch {
try {
val credentialsDeferred: Deferred<Application?> = async {
try {
pixelfedAPI.registerApplication(
appName, "$oauthScheme://$PACKAGE_ID", SCOPE, "https://pixeldroid.org"
)
} catch (exception: Exception) {
return@async null
}
}
val nodeInfoJRD = pixelfedAPI.wellKnownNodeInfo()
val credentials = credentialsDeferred.await()
val clientId = credentials?.client_id ?: return@launch failedRegistration()
preferences.edit()
.putString("clientID", clientId)
.putString("clientSecret", credentials.client_secret)
.apply()
// c.f. https://nodeinfo.diaspora.software/protocol.html for more info
val nodeInfoSchemaUrl = nodeInfoJRD.links.firstOrNull {
it.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"
}?.href ?: return@launch failedRegistration(getString(R.string.instance_error))
nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@launch failedRegistration()
}
}
}
private suspend fun nodeInfoSchema(
normalizedDomain: String,
clientId: String,
nodeInfoSchemaUrl: String
) = coroutineScope {
val nodeInfo: NodeInfo = try {
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
}
val domain: String = try {
if (nodeInfo.hasInstanceEndpointInfo()) {
preferences.edit().putString("nodeInfo", Gson().toJson(nodeInfo)).remove("instance").apply()
nodeInfo.metadata?.config?.site?.url
} else {
val instance: Instance = try {
pixelfedAPI.instance()
} catch (exception: Exception) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
}
preferences.edit().putString("instance", Gson().toJson(instance)).remove("nodeInfo").apply()
instance.uri
}
} catch (e: IllegalArgumentException){ null }
?: return@coroutineScope failedRegistration(getString(R.string.instance_error))
preferences.edit()
.putString("domain", normalizeDomain(domain))
.apply()
if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.instance_not_pixelfed_warning)
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
promptOAuth(normalizedDomain, clientId)
}
setNegativeButton(R.string.instance_not_pixelfed_cancel) { _, _ ->
loadingAnimation(false)
wipeSharedSettings()
}
}.show()
} else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.api_not_enabled_dialog)
setNegativeButton(android.R.string.ok) { _, _ ->
loadingAnimation(false)
wipeSharedSettings()
}
}.show()
} else {
promptOAuth(normalizedDomain, clientId)
}
}
private fun promptOAuth(normalizedDomain: String, client_id: String) {
val url = "$normalizedDomain/oauth/authorize?" +
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=${SCOPE.replace(" ", "%20")}"
if (!openUrl(url)) return failedRegistration(getString(R.string.browser_launch_failed))
}
private fun authenticate(code: String?) {
// Get previous values from preferences
val domain = preferences.getString("domain", "") as String
val clientId = preferences.getString("clientID", "") as String
val clientSecret = preferences.getString("clientSecret", "") as String
if (code.isNullOrBlank() || domain.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
return failedRegistration(getString(R.string.auth_failed))
}
//Successful authorization
pixelfedAPI = PixelfedAPI.createFromUrl(domain)
val gson = Gson()
val nodeInfo: NodeInfo? = gson.fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
val instance: Instance? = gson.fromJson(preferences.getString("instance", null), Instance::class.java)
lifecycleScope.launch {
try {
val token = pixelfedAPI.obtainToken(
clientId, clientSecret, "$oauthScheme://$PACKAGE_ID", SCOPE, code,
"authorization_code"
)
if (token.access_token == null) {
return@launch failedRegistration(getString(R.string.token_error))
}
storeInstance(db, nodeInfo, instance)
storeUser(
token.access_token,
token.refresh_token,
clientId,
clientSecret,
domain
)
wipeSharedSettings()
} catch (exception: Exception) {
return@launch failedRegistration(getString(R.string.token_error))
}
}
}
private fun failedRegistration(message: String = getString(R.string.registration_failed)) {
loadingAnimation(false)
binding.editText.error = message
wipeSharedSettings()
}
private fun wipeSharedSettings(){
preferences.edit().clear().apply()
}
private fun loadingAnimation(on: Boolean){
if(on) {
binding.loginActivityInstanceInputLayout.visibility = View.GONE
binding.progressLayout.visibility = View.VISIBLE
}
else {
binding.loginActivityInstanceInputLayout.visibility = inputVisibility
binding.progressLayout.visibility = View.GONE
}
}
private suspend fun storeUser(accessToken: String, refreshToken: String?, clientId: String, clientSecret: String, instance: String) {
try {
val user = pixelfedAPI.verifyCredentials("Bearer $accessToken")
db.userDao().deActivateActiveUsers()
addUser(
db,
user,
instance,
activeUser = true,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
apiHolder.setToCurrentUser()
} catch (exception: Exception) {
return failedRegistration(getString(R.string.verify_credentials))
}
fetchNotifications()
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
// Fetch the latest notifications of this account, to avoid launching old notifications
private suspend fun fetchNotifications() {
val user = db.userDao().getActiveUser()!!
try {
val notifications = apiHolder.api!!.notifications()
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
db.notificationDao().insertAll(notifications)
} catch (exception: Exception) {
return failedRegistration(getString(R.string.login_notifications))
}
makeNotificationChannels(
applicationContext,
user.fullHandle,
makeChannelGroupId(user)
)
}
}

@ -0,0 +1,499 @@
package org.pixeldroid.app
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.GravityCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.ExperimentalPagingApi
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.bumptech.glide.Glide
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.color.DynamicColors
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.*
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import kotlinx.coroutines.launch
import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist
import org.pixeldroid.app.databinding.ActivityMainBinding
import org.pixeldroid.app.directMessages.ConversationsActivity
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.posts.NestedScrollableHost
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.notifications.NotificationsFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds.PostFeedFragment
import org.pixeldroid.app.profile.ProfileActivity
import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment
import org.pixeldroid.app.settings.SettingsActivity
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.hasInternet
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications
import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount
import java.time.Instant
class MainActivity : BaseActivity() {
private lateinit var header: AccountHeaderView
private var user: UserDatabaseEntity? = null
companion object {
const val ADD_ACCOUNT_IDENTIFIER: Long = -13
}
private lateinit var binding: ActivityMainBinding
@OptIn(ExperimentalPagingApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setOnExitAnimationListener {
it.remove()
}
// Workaround for dynamic colors not applying due to splash screen?
DynamicColors.applyToActivityIfAvailable(this)
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//get the currently active user
user = db.userDao().getActiveUser()
if (notificationFromOtherUser()) return
//Check if we have logged in and gotten an access token
if (user == null) {
finish()
launchActivity(LoginActivity(), firstTime = true)
} else {
sendTraceDroidStackTracesIfExist("contact@pixeldroid.org", this)
setupDrawer()
val tabs: List<() -> Fragment> = listOf(
{
PostFeedFragment<HomeStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", true) }
}
},
{ SearchDiscoverFragment() },
{ CameraFragment() },
{ NotificationsFragment() },
{
PostFeedFragment<PublicFeedStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", false) }
}
}
)
setupTabs(tabs)
val showNotification: Boolean = intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)
if(showNotification){
binding.viewPager.currentItem = 3
}
if (ActivityCompat.checkSelfPermission(applicationContext,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) enablePullNotifications(this)
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) enablePullNotifications(this)
}
// Checks if the activity was launched from a notification from another account than the
// current active one, and if so switches to that account
private fun notificationFromOtherUser(): Boolean {
val userOfNotification: String? = intent.extras?.getString(USER_NOTIFICATION_TAG)
val instanceOfNotification: String? = intent.extras?.getString(INSTANCE_NOTIFICATION_TAG)
if (userOfNotification != null && instanceOfNotification != null
&& (userOfNotification != user?.user_id
|| instanceOfNotification != user?.instance_uri)
) {
switchUser(userOfNotification, instanceOfNotification)
val newIntent = Intent(this, MainActivity::class.java)
newIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)) {
newIntent.putExtra(SHOW_NOTIFICATION_TAG, true)
}
finish()
startActivity(newIntent)
return true
}
return false
}
private fun setupDrawer() {
binding.mainDrawerButton.setOnClickListener{
binding.drawerLayout.openDrawer(binding.drawer)
}
header = AccountHeaderView(this).apply {
attachToSliderView(binding.drawer)
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean ->
clickProfile(profile, current)
}
addProfile(ProfileSettingDrawerItem().apply {
identifier = ADD_ACCOUNT_IDENTIFIER
nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add
}, 0)
dividerBelowHeader = false
closeDrawerOnProfileListClick = true
}
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
Glide.with(this@MainActivity)
.load(uri)
.placeholder(placeholder)
.into(imageView)
}
override fun cancel(imageView: ImageView) {
Glide.with(this@MainActivity).clear(imageView)
}
override fun placeholder(ctx: Context, tag: String?): Drawable {
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
return ContextCompat.getDrawable(ctx, R.drawable.ic_default_user)!!
}
return super.placeholder(ctx, tag)
}
})
fillDrawerAccountInfo(user!!.user_id)
//after setting with the values in the db, we make sure to update the database and apply
//with the received one. This happens asynchronously.
getUpdatedAccount()
binding.drawer.itemAdapter.add(
primaryDrawerItem {
nameRes = R.string.menu_account
iconicsIcon = GoogleMaterial.Icon.gmd_person
},
primaryDrawerItem {
nameRes = R.string.menu_settings
iconicsIcon = GoogleMaterial.Icon.gmd_settings
},
primaryDrawerItem {
nameRes = R.string.logout
iconicsIcon = GoogleMaterial.Icon.gmd_close
},
)
binding.drawer.onDrawerItemClickListener = { v, drawerItem, position ->
when (position){
1 -> launchActivity(ProfileActivity())
2 -> launchActivity(SettingsActivity())
3 -> logOut()
4 -> launchActivity(ConversationsActivity())
}
false
}
// Closes the drawer if it is open, when we press the back button
onBackPressedDispatcher.addCallback(this) {
// Handle the back button event
if(binding.drawerLayout.isDrawerOpen(GravityCompat.START)){
binding.drawerLayout.closeDrawer(GravityCompat.START)
}
else {
this.isEnabled = false
super.onBackPressedDispatcher.onBackPressed()
}
}
}
private fun logOut(){
finish()
removeNotificationChannelsFromAccount(applicationContext, user)
db.runInTransaction {
db.userDao().deleteActiveUsers()
val remainingUsers = db.userDao().getAll()
if (remainingUsers.isEmpty()){
//no more users, start first-time login flow
launchActivity(LoginActivity(), firstTime = true)
} else {
val newActive = remainingUsers.first()
db.userDao().activateUser(newActive.user_id, newActive.instance_uri)
apiHolder.setToCurrentUser()
//relaunch the app
launchActivity(MainActivity(), firstTime = true)
}
}
}
private fun getUpdatedAccount() {
if (hasInternet(applicationContext)) {
lifecycleScope.launchWhenCreated {
try {
val domain = user?.instance_uri.orEmpty()
val accessToken = user?.accessToken.orEmpty()
val refreshToken = user?.refreshToken
val clientId = user?.clientId.orEmpty()
val clientSecret = user?.clientSecret.orEmpty()
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val account = api.verifyCredentials()
addUser(db, account, domain, accessToken = accessToken, refreshToken = refreshToken, clientId = clientId, clientSecret = clientSecret)
fillDrawerAccountInfo(account.id!!)
} catch (exception: Exception) {
Log.e("ACCOUNT UPDATE:", exception.toString())
}
}
}
}
//called when switching profiles, or when clicking on current profile
private fun clickProfile(profile: IProfile, current: Boolean): Boolean {
if(current){
launchActivity(ProfileActivity())
return false
}
//Clicked on add new account
if(profile.identifier == ADD_ACCOUNT_IDENTIFIER){
launchActivity(LoginActivity())
return false
}
switchUser(profile.identifier.toString(), profile.tag as String)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
finish()
startActivity(intent)
return false
}
private fun switchUser(userId: String, instance_uri: String) {
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(userId, instance_uri)
apiHolder.setToCurrentUser()
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private fun fillDrawerAccountInfo(account: String) {
val users = db.userDao().getAll().toMutableList()
users.sortWith { l, r ->
when {
l.isActive && !r.isActive -> -1
r.isActive && !l.isActive -> 1
else -> 0
}
}
val profiles: MutableList<IProfile> = users.map { user ->
ProfileDrawerItem().apply {
isSelected = user.isActive
nameText = user.display_name
iconUrl = user.avatar_static
isNameShown = true
identifier = user.user_id.toLong()
descriptionText = user.fullHandle
tag = user.instance_uri
}
}.toMutableList()
// reuse the already existing "add account" item
header.profiles.orEmpty()
.filter { it.identifier == ADD_ACCOUNT_IDENTIFIER }
.take(1)
.forEach { profiles.add(it) }
header.clear()
header.profiles = profiles
header.setActiveProfile(account.toLong())
}
/**
* Use reflection to make it a bit harder to swipe between tabs
*/
private fun ViewPager2.reduceDragSensitivity() {
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
recyclerViewField.isAccessible = true
val recyclerView = recyclerViewField.get(this) as RecyclerView
val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop")
touchSlopField.isAccessible = true
val touchSlop = touchSlopField.get(recyclerView) as Int
touchSlopField.set(recyclerView, touchSlop*NestedScrollableHost.touchSlopModifier)
}
private fun setupTabs(tab_array: List<() -> Fragment>){
binding.viewPager.reduceDragSensitivity()
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
override fun createFragment(position: Int): Fragment {
return tab_array[position]()
}
override fun getItemCount(): Int {
return tab_array.size
}
}
binding.viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val selected = when(position){
0 -> R.id.page_1
1 -> R.id.page_2
2 -> R.id.page_3
3 -> {
setNotificationBadge(false)
R.id.page_4
}
4 -> R.id.page_5
else -> null
}
if (selected != null) {
binding.tabs.selectedItemId = selected
}
super.onPageSelected(position)
}
})
fun MenuItem.itemPos(): Int? {
return when(itemId){
R.id.page_1 -> 0
R.id.page_2 -> 1
R.id.page_3 -> 2
R.id.page_4 -> 3
R.id.page_5 -> 4
else -> null
}
}
binding.tabs.setOnItemSelectedListener {item ->
item.itemPos()?.let {
binding.viewPager.currentItem = it
true
} ?: false
}
binding.tabs.setOnItemReselectedListener { item ->
item.itemPos()?.let { position ->
val page =
//No clue why this works but it does. F to pay respects
supportFragmentManager.findFragmentByTag("f$position")
(page as? CachedFeedFragment<*>)?.onTabReClicked()
}
}
// Fetch one notification to show a badge if there are new notifications
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
user?.let {
val lastNotification = db.notificationDao().latestNotification(it.user_id, it.instance_uri)
try {
val notification: List<Notification>? = apiHolder.api?.notifications(
min_id = lastNotification?.id,
limit = "20"
)
val filtered = notification?.filter { notification ->
lastNotification == null || (notification.created_at
?: Instant.MIN) > (lastNotification.created_at ?: Instant.MIN)
}
val numberOfNewNotifications = if((filtered?.size ?: 20) >= 20) null else filtered?.size
if(filtered?.isNotEmpty() == true ) setNotificationBadge(true, numberOfNewNotifications)
} catch (exception: Exception) {
return@repeatOnLifecycle
}
}
}
}
}
private fun setNotificationBadge(show: Boolean, count: Int? = null){
if(show){
val badge = binding.tabs.getOrCreateBadge(R.id.page_4)
if (count != null) badge.number = count
}
else binding.tabs.removeBadge(R.id.page_4)
}
fun BottomNavigationView.uncheckAllItems() {
menu.setGroupCheckable(0, true, false)
for (i in 0 until menu.size()) {
menu.getItem(i).isChecked = false
}
menu.setGroupCheckable(0, true, true)
}
/**
* Launches the given activity and put it as the current one
* @param firstTime to true means the task history will be reset (as if the app were
* launched anew into this activity)
*/
private fun launchActivity(activity: AppCompatActivity, firstTime: Boolean = false) {
val intent = Intent(this, activity::class.java)
if(firstTime){
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
}
}

@ -0,0 +1,19 @@
package org.pixeldroid.app.directMessages
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import org.pixeldroid.app.R
import org.pixeldroid.app.directMessages.ui.main.ConversationsFragment
class ConversationsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_conversations)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, ConversationsFragment.newInstance())
.commitNow()
}
}
}

@ -0,0 +1,32 @@
package org.pixeldroid.app.directMessages.ui.main
import androidx.lifecycle.ViewModelProvider
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.pixeldroid.app.R
class ConversationsFragment : Fragment() {
companion object {
fun newInstance() = ConversationsFragment()
}
private lateinit var viewModel: ConversationsViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(ConversationsViewModel::class.java)
// TODO: Use the ViewModel to watch the variable containing DM data and show it
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_main, container, false)
}
}

@ -0,0 +1,25 @@
package org.pixeldroid.app.directMessages.ui.main
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import javax.inject.Inject
class ConversationsViewModel(application: Application) : AndroidViewModel(application) {
// TODO: Implement the ViewModel
// API calls for DM, store results in some variable here
@Inject
lateinit var apiHolder: PixelfedAPIHolder
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
}
suspend fun loadConversations() {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val conversations = api.viewAllConversations()
}
}

@ -1,95 +0,0 @@
package org.pixeldroid.app.directmessages
import android.content.Context
import android.graphics.Canvas
import android.widget.EdgeEffect
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import androidx.recyclerview.widget.RecyclerView
import org.pixeldroid.common.pxToDp
import kotlin.math.absoluteValue
/** The magnitude of translation distance while the list is over-scrolled. */
private const val OVERSCROLL_TRANSLATION_MAGNITUDE = 0.4f
/** The magnitude of translation distance when the list reaches the edge on fling. */
private const val FLING_TRANSLATION_MAGNITUDE = 0.5f
/**
* Replace edge effect by a bounce
*/
class BounceEdgeEffectFactory(val refreshCallback: () -> Unit, val context: Context) : RecyclerView.EdgeEffectFactory() {
override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {
return object : EdgeEffect(recyclerView.context) {
// A reference to the [SpringAnimation] for this RecyclerView used to bring the item back after the over-scroll effect.
var translationAnim: SpringAnimation? = null
override fun onPull(deltaDistance: Float) {
super.onPull(deltaDistance)
handlePull(deltaDistance)
}
override fun onPull(deltaDistance: Float, displacement: Float) {
super.onPull(deltaDistance, displacement)
handlePull(deltaDistance)
}
private fun handlePull(deltaDistance: Float) {
// This is called on every touch event while the list is scrolled with a finger.
// Translate the recyclerView with the distance
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
val translationYDelta = sign * recyclerView.width * deltaDistance * OVERSCROLL_TRANSLATION_MAGNITUDE
recyclerView.translationY += translationYDelta
translationAnim?.cancel()
}
override fun onRelease() {
super.onRelease()
// The finger is lifted. Start the animation to bring translation back to the resting state.
if (recyclerView.translationY != 0f) {
if (direction == DIRECTION_BOTTOM && recyclerView.translationY.toInt().absoluteValue.pxToDp(context) > 50) {
refreshCallback()
}
translationAnim = createAnim()?.also { it.start() }
}
}
override fun onAbsorb(velocity: Int) {
super.onAbsorb(velocity)
// The list has reached the edge on fling.
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
translationAnim?.cancel()
translationAnim = createAnim().setStartVelocity(translationVelocity)?.also { it.start() }
}
override fun draw(canvas: Canvas?): Boolean {
// don't paint the usual edge effect
return false
}
override fun isFinished(): Boolean {
// Without this, will skip future calls to onAbsorb()
return translationAnim?.isRunning?.not() ?: true
}
private fun createAnim() = SpringAnimation(recyclerView, SpringAnimation.TRANSLATION_Y)
.setSpring(
SpringForce()
.setFinalPosition(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(SpringForce.STIFFNESS_LOW)
)
}
}
}

@ -1,109 +0,0 @@
package org.pixeldroid.app.directmessages
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityConversationBinding
import org.pixeldroid.app.directmessages.ConversationFragment.Companion.CONVERSATION_ID
import org.pixeldroid.app.directmessages.ConversationFragment.Companion.PROFILE_ID
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
class ConversationActivity : BaseActivity() {
lateinit var binding: ActivityConversationBinding
private lateinit var conversationFragment: ConversationFragment
companion object {
const val USERNAME = "ConversationActivityUsername"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityConversationBinding.inflate(layoutInflater)
conversationFragment = ConversationFragment()
setContentView(binding.root)
setSupportActionBar(binding.topBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val userName = intent?.getSerializableExtra(USERNAME) as? String?
supportActionBar?.title = getString(R.string.dm_title, userName)
val conversationId = intent?.getSerializableExtra(CONVERSATION_ID) as String
val pid = intent?.getSerializableExtra(PROFILE_ID) as String
activateCommenter(pid)
initConversationFragment(pid, conversationId, savedInstanceState)
}
private fun activateCommenter(pid: String) {
//Activate commenter
binding.submitComment.setOnClickListener {
val textIn = binding.editComment.text
//Open text input
if(textIn.isNullOrEmpty()) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.empty_comment),
Toast.LENGTH_SHORT
).show()
} else {
//Post the comment
lifecycleScope.launchWhenCreated {
apiHolder.api?.let { it1 -> sendMessage(it1, pid) }
}
}
}
}
private fun initConversationFragment(profileId: String, conversationId: String, savedInstanceState: Bundle?) {
val arguments = Bundle()
arguments.putSerializable(CONVERSATION_ID, conversationId)
arguments.putSerializable(PROFILE_ID, profileId)
conversationFragment.arguments = arguments
//TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
//Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.conversationFragment, conversationFragment)
}
}
}
suspend fun sendMessage(
api: PixelfedAPI,
pid: String,
) {
val textIn = binding.editComment.text
val nonNullText = textIn.toString()
try {
binding.submitComment.isEnabled = false
binding.editComment.isEnabled = false
api.sendDirectMessage(pid, nonNullText)
//Reload to add the comment to the comment section
conversationFragment.adapter.refresh()
binding.editComment.isEnabled = true
binding.editComment.text = null
binding.submitComment.isEnabled = true
} catch (exception: Exception) {
Log.e("DM SEND ERROR", exception.toString())
Toast.makeText(
binding.root.context, binding.root.context.getString(R.string.comment_error),
Toast.LENGTH_SHORT
).show()
}
}
}

@ -1,188 +0,0 @@
package org.pixeldroid.app.directmessages
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.DirectMessagesConversationItemBinding
import org.pixeldroid.app.posts.AlbumActivity
import org.pixeldroid.app.posts.AlbumViewModel
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedContentRepository
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.api.objects.Message
import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
/**
* Fragment for one Direct Messages conversation
*/
class ConversationFragment : CachedFeedFragment<DirectMessageDatabaseEntity>() {
companion object {
const val CONVERSATION_ID = "ConversationFragmentConversationId"
const val PROFILE_ID = "ConversationFragmentProfileId"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = DirectMessagesListAdapter(apiHolder)
}
@OptIn(ExperimentalPagingApi::class)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.createView(inflater, container, savedInstanceState, true)
val pid = arguments?.getSerializable(PROFILE_ID) as String
val conversationId = arguments?.getSerializable(CONVERSATION_ID) as String
val dao = db.directMessagesConversationDao()
val remoteMediator = ConversationRemoteMediator(apiHolder, db, pid, conversationId)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(
requireActivity(),
ViewModelFactory(
db, dao, remoteMediator,
FeedContentRepository(db, dao, remoteMediator, conversationId)
)
)["directMessagesConversation", FeedViewModel::class.java] as FeedViewModel<DirectMessageDatabaseEntity>
launch()
initSearch()
return view
}
/**
* View Holder for a [Conversation] RecyclerView list item.
*/
class DirectMessagesConversationViewHolder(val binding: DirectMessagesConversationItemBinding) : RecyclerView.ViewHolder(binding.root) {
private var message: DirectMessageDatabaseEntity? = null
init {
itemView.setOnClickListener {
message?.let {
if (it.type == "photo") {
val intent = Intent(itemView.context, AlbumActivity::class.java)
intent.putExtra(AlbumViewModel.ALBUM_IMAGES, ArrayList(it.carousel.orEmpty()))
intent.putExtra(AlbumViewModel.ALBUM_INDEX, 0)
itemView.context.startActivity(intent)
}
}
}
}
fun bind(
message: DirectMessageDatabaseEntity?,
api: PixelfedAPIHolder,
lifecycleScope: LifecycleCoroutineScope,
) {
this.message = message
if(message?.isAuthor == true) {
binding.messageIncoming.visibility = GONE
binding.messageOutgoing.visibility = VISIBLE
binding.textMessageOutgoing.text = message.text
} else {
binding.messageIncoming.visibility = VISIBLE
binding.messageOutgoing.visibility = GONE
binding.textMessageIncoming.text = message?.text ?: ""
}
if (message?.type == "photo"){
binding.imageMessageIncoming.visibility = VISIBLE
binding.imageMessageOutgoing.visibility = VISIBLE
binding.textMessageOutgoing.visibility = GONE
binding.textMessageOutgoing.visibility = GONE
Glide.with(if(message.isAuthor == true) binding.imageMessageOutgoing else binding.imageMessageIncoming)
.load(message.media)
.into(if(message.isAuthor == true) binding.imageMessageOutgoing else binding.imageMessageIncoming)
} else {
binding.imageMessageIncoming.visibility = GONE
binding.imageMessageOutgoing.visibility = GONE
binding.textMessageOutgoing.visibility = VISIBLE
binding.textMessageIncoming.visibility = VISIBLE
}
message?.created_at.let {
// if (it == null) binding.messageTime.text = ""
// else setTextViewFromISO8601(
// it,
// binding.messageTime,
// false
// )
}
}
companion object {
fun create(parent: ViewGroup): DirectMessagesConversationViewHolder {
val itemBinding = DirectMessagesConversationItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return DirectMessagesConversationViewHolder(itemBinding)
}
}
}
inner class DirectMessagesListAdapter(
private val apiHolder: PixelfedAPIHolder,
) : PagingDataAdapter<DirectMessageDatabaseEntity, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<DirectMessageDatabaseEntity>() {
override fun areItemsTheSame(
oldItem: DirectMessageDatabaseEntity,
newItem: DirectMessageDatabaseEntity
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: DirectMessageDatabaseEntity,
newItem: DirectMessageDatabaseEntity
): Boolean =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DirectMessagesConversationViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.direct_messages_conversation_item
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel?.let {
(holder as DirectMessagesConversationViewHolder).bind(
it,
apiHolder,
lifecycleScope
)
}
}
}
}

@ -1,74 +0,0 @@
package org.pixeldroid.app.directmessages
import android.util.Log
import androidx.paging.*
import androidx.room.withTransaction
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
import java.lang.Exception
import java.lang.NullPointerException
import javax.inject.Inject
/**
* RemoteMediator for a Direct Messages conversation.
*
* A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
* source into a local source wrapped by a [PagingSource], e.g., loading data from network into
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class ConversationRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase,
private val pid: String,
private val conversationId: String
) : RemoteMediator<Int, DirectMessageDatabaseEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, DirectMessageDatabaseEntity>): MediatorResult {
try {
val user = db.userDao().getActiveUser()
?: return MediatorResult.Error(NullPointerException("No active user exists"))
val nextPage = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> {
// No prepend for the moment, might be nice to add later
db.directMessagesConversationDao().lastMessageId(user.user_id, user.instance_uri, conversationId)
?: return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND ->
return MediatorResult.Success(endOfPaginationReached = true)
}
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val apiResponse =
api.directMessagesConversation(
pid = pid,
max_id = nextPage,
)
//TODO prepend
val messages = apiResponse.messages.map {
DirectMessageDatabaseEntity(
it,
conversationId,
user
)
}
val endOfPaginationReached = messages.isEmpty()
db.withTransaction {
// Clear table in the database
if (loadType == LoadType.REFRESH) {
db.directMessagesConversationDao().clearFeedContent(user.user_id, user.instance_uri, conversationId)
}
db.directMessagesConversationDao().insertAll(messages)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: Exception){
return MediatorResult.Error(exception)
}
}
}

@ -1,119 +0,0 @@
package org.pixeldroid.app.directmessages
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.databinding.NewDmDialogBinding
import org.pixeldroid.app.utils.BaseActivity
class DirectMessagesActivity : BaseActivity() {
lateinit var binding: ActivityFollowersBinding
private lateinit var conversationFragment: DirectMessagesFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFollowersBinding.inflate(layoutInflater)
conversationFragment = DirectMessagesFragment()
setContentView(binding.root)
setSupportActionBar(binding.topBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setTitle(R.string.direct_messages)
initConversationFragment(savedInstanceState)
addFab()
}
private fun addFab() {
// Create a Floating Action Button
val fab = FloatingActionButton(this).apply {
id = View.generateViewId()
setImageResource(android.R.drawable.ic_dialog_email) // Example icon
ViewCompat.setElevation(this, 8f) // Set elevation if needed
}
// Set LayoutParams for the FAB
val fabParams = CoordinatorLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
gravity = android.view.Gravity.END or android.view.Gravity.BOTTOM
marginEnd = 16
bottomMargin = 16
}
// Add the FAB to the CoordinatorLayout
binding.coordinatorFollowers.addView(fab, fabParams)
fab.setOnClickListener {
newDirectMessage()
}
}
private fun newDirectMessage() {
val newDmDialogBinding =
NewDmDialogBinding.inflate(LayoutInflater.from(this))
MaterialAlertDialogBuilder(this)
.setTitle(R.string.new_dm_conversation)
.setMessage(R.string.dm_instruction)
.setView(newDmDialogBinding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
val name = newDmDialogBinding.dmTarget.text.toString()
val text = newDmDialogBinding.dmText.text.toString()
lifecycleScope.launch {
try {
apiHolder.api?.let {
val pid = it.lookupUser(name, remote = name.count { it == '@' } == 2)
.firstOrNull {
it.name == name
}?.id ?: return@launch errorSending()
it.sendDirectMessage(pid, text)
}
} catch (e: Exception) {
Log.e("DirectMessagesActivity", e.toString())
errorSending()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
//
// conversation?.accounts?.firstOrNull()?.let {
// val intent = Intent(itemView.context, ConversationActivity::class.java).apply {
// putExtra(PROFILE_ID, it.id)
// putExtra(CONVERSATION_ID, conversation?.id)
// putExtra(USERNAME, it.getDisplayName())
// }
// startActivity(intent)
// }
}
private fun errorSending() {
Snackbar.make(binding.root, R.string.new_dm_error, Snackbar.LENGTH_LONG).show()
}
private fun initConversationFragment(savedInstanceState: Bundle?) {
//TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
//Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.conversationFragment, conversationFragment)
}
}
}
}

@ -1,171 +0,0 @@
package org.pixeldroid.app.directmessages
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.DirectMessagesListItemBinding
import org.pixeldroid.app.directmessages.ConversationActivity.Companion.USERNAME
import org.pixeldroid.app.directmessages.ConversationFragment.Companion.CONVERSATION_ID
import org.pixeldroid.app.directmessages.ConversationFragment.Companion.PROFILE_ID
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
import org.pixeldroid.app.posts.parseHTMLText
import org.pixeldroid.app.posts.setTextViewFromISO8601
import org.pixeldroid.app.profile.ProfileActivity
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
/**
* Fragment for the list of Direct Messages conversations.
*/
class DirectMessagesFragment : CachedFeedFragment<Conversation>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = DirectMessagesListAdapter(apiHolder)
}
@OptIn(ExperimentalPagingApi::class)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(
requireActivity(),
ViewModelFactory(db, db.directMessagesDao(), DirectMessagesRemoteMediator(apiHolder, db))
)["directMessagesList", FeedViewModel::class.java] as FeedViewModel<Conversation>
launch()
initSearch()
return view
}
/**
* View Holder for a [Conversation] RecyclerView list item.
*/
class DirectMessagesListViewHolder(val binding: DirectMessagesListItemBinding) : RecyclerView.ViewHolder(binding.root) {
private var conversation: Conversation? = null
init {
itemView.setOnClickListener {
conversation?.accounts?.firstOrNull()?.let {
val intent = Intent(itemView.context, ConversationActivity::class.java).apply {
putExtra(PROFILE_ID, it.id)
putExtra(CONVERSATION_ID, conversation?.id)
putExtra(USERNAME, it.getDisplayName())
}
itemView.context.startActivity(intent)
}
}
binding.dmAvatar.setOnClickListener {
conversation?.accounts?.firstOrNull()?.let {
val intent = Intent(itemView.context, ProfileActivity::class.java).apply {
putExtra(Account.ACCOUNT_TAG, it)
}
itemView.context.startActivity(intent)
}
}
}
fun bind(
conversation: Conversation?,
api: PixelfedAPIHolder,
lifecycleScope: LifecycleCoroutineScope,
) {
this.conversation = conversation
val account = conversation?.accounts?.firstOrNull()
Glide.with(itemView).load(account?.anyAvatar()).circleCrop()
.into(binding.dmAvatar)
binding.dmUsername.text = account?.getDisplayName()
binding.dmLastMessage.text = parseHTMLText(
conversation?.last_status?.content ?: "",
conversation?.last_status?.mentions,
api,
itemView.context,
lifecycleScope
)
conversation?.last_status?.created_at.let {
if (it == null) binding.messageTime.text = ""
else setTextViewFromISO8601(
it,
binding.messageTime,
false
)
}
}
companion object {
fun create(parent: ViewGroup): DirectMessagesListViewHolder {
val itemBinding = DirectMessagesListItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return DirectMessagesListViewHolder(itemBinding)
}
}
}
inner class DirectMessagesListAdapter(
private val apiHolder: PixelfedAPIHolder,
) : PagingDataAdapter<Conversation, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<Conversation>() {
override fun areItemsTheSame(
oldItem: Conversation,
newItem: Conversation
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: Conversation,
newItem: Conversation
): Boolean =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DirectMessagesListViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.direct_messages_list_item
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel?.let {
(holder as DirectMessagesListViewHolder).bind(
it,
apiHolder,
lifecycleScope
)
}
}
}
}

@ -1,206 +0,0 @@
package org.pixeldroid.app.login
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.pixeldroid.app.BuildConfig
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityLoginBinding
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.settings.SettingsActivity
import org.pixeldroid.app.settings.TutorialSettingsDialog.Companion.START_TUTORIAL
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.openUrl
/**
Overview of the flow of the login process: (boxes are requests done in parallel,
since they do not depend on each other)
_________________________________
|[PixelfedAPI.registerApplication]|
|[PixelfedAPI.wellKnownNodeInfo] |
̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅
+----> [PixelfedAPI.nodeInfoSchema] (and then [PixelfedAPI.instance] if needed)
+----> [promptOAuth]
+----> [PixelfedAPI.obtainToken]
+----> [PixelfedAPI.verifyCredentials]
*/
class LoginActivity : BaseActivity() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private lateinit var oauthScheme: String
private lateinit var preferences: SharedPreferences
private lateinit var binding: ActivityLoginBinding
val model: LoginActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
oauthScheme = getString(R.string.auth_scheme)
preferences = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
binding.connectInstanceButton.setOnClickListener {
hideKeyboard()
model.registerAppToServer(binding.editText.text.toString())
}
binding.whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() }
// Enter button on keyboard should press the connect button
binding.editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
binding.connectInstanceButton.performClick()
return@setOnEditorActionListener true
}
false
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.promptOauth.collectLatest {
it?.let {
if (it.launch) promptOAuth(it.normalizedDomain, it.clientId)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.finishedLogin.collectLatest {
when (it) {
LoginActivityViewModel.FinishedLogin.Finished -> {
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
LoginActivityViewModel.FinishedLogin.FinishedFirstTime -> MaterialAlertDialogBuilder(binding.root.context)
.setMessage(R.string.first_time_question)
.setPositiveButton(android.R.string.ok) { _, _ ->
val intent = Intent(this@LoginActivity, SettingsActivity::class.java)
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(START_TUTORIAL, true)
startActivity(intent)
}
.setNegativeButton(R.string.skip_tutorial) { _, _ -> model.finishLogin()}
.show()
LoginActivityViewModel.FinishedLogin.NotFinished -> {}
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.loadingState.collectLatest {
when(it.loginState){
LoginActivityViewModel.LoginState.LoadingState.Resting -> loadingAnimation(false)
LoginActivityViewModel.LoginState.LoadingState.Busy -> loadingAnimation(true)
LoginActivityViewModel.LoginState.LoadingState.Error -> failedRegistration(it.error!!)
}
}
}
}
}
override fun onStart() {
super.onStart()
val url: Uri? = intent.data
//Check if the activity was started after the authentication
if (url == null || !url.toString().startsWith("$oauthScheme://$PACKAGE_ID")) return
val code = url.getQueryParameter("code")
model.authenticate(code)
}
private fun whatsAnInstance() {
MaterialAlertDialogBuilder(this)
.setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
.setPositiveButton(android.R.string.ok) { _, _ -> }
// Create the AlertDialog
.show()
}
private fun hideKeyboard() {
val view = currentFocus
if (view != null) {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
view.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
}
}
private fun promptOAuth(normalizedDomain: String, client_id: String) {
val url = "$normalizedDomain/oauth/authorize?" +
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=${SCOPE.replace(" ", "%20")}"
if (!openUrl(url)) model.oauthLaunchFailed()
else model.oauthLaunched()
}
private fun failedRegistration(@StringRes message: Int = R.string.registration_failed) {
when (message) {
R.string.instance_not_pixelfed_warning -> MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.instance_not_pixelfed_warning)
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
model.dialogAckedContinueAnyways()
}
setNegativeButton(R.string.instance_not_pixelfed_cancel) { _, _ ->
model.dialogNegativeButtonClicked()
}
}.show()
R.string.api_not_enabled_dialog -> MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.api_not_enabled_dialog)
setNegativeButton(android.R.string.ok) { _, _ ->
model.dialogNegativeButtonClicked()
}
}.show()
else -> binding.editText.error = getString(message)
}
loadingAnimation(false)
}
private fun loadingAnimation(on: Boolean){
if(on) {
binding.loginActivityInstanceInputLayout.visibility = View.GONE
binding.progressLayout.visibility = View.VISIBLE
}
else {
binding.loginActivityInstanceInputLayout.visibility = View.VISIBLE
binding.progressLayout.visibility = View.GONE
}
}
}

@ -1,293 +0,0 @@
package org.pixeldroid.app.login
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.BuildConfig
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Application
import org.pixeldroid.app.utils.api.objects.Instance
import org.pixeldroid.app.utils.api.objects.NodeInfo
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.storeInstance
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.normalizeDomain
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
import org.pixeldroid.app.utils.validDomain
import javax.inject.Inject
@HiltViewModel
class LoginActivityViewModel @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase,
@ApplicationContext private val applicationContext: Context,
) : ViewModel() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private val oauthScheme = applicationContext.getString(R.string.auth_scheme)
private lateinit var pixelfedAPI: PixelfedAPI
private val preferences: SharedPreferences = applicationContext.getSharedPreferences(
PREFERENCE_NAME, Context.MODE_PRIVATE)
private val _loadingState: MutableStateFlow<LoginState> = MutableStateFlow(LoginState(LoginState.LoadingState.Resting))
val loadingState = _loadingState.asStateFlow()
enum class FinishedLogin {
NotFinished, Finished, FinishedFirstTime
}
private val _finishedLogin = MutableStateFlow(FinishedLogin.NotFinished)
val finishedLogin = _finishedLogin.asStateFlow()
private val _promptOauth: MutableStateFlow<PromptOAuth?> = MutableStateFlow(null)
val promptOauth = _promptOauth.asStateFlow()
data class PromptOAuth(
val launch: Boolean,
val normalizedDomain: String,
val clientId: String,
)
data class LoginState(
val loginState: LoadingState,
@StringRes
val error: Int? = null,
) {
init {
if (loginState == LoadingState.Error && error == null) throw IllegalArgumentException()
}
enum class LoadingState {
Resting, Busy, Error
}
}
fun registerAppToServer(rawDomain: String) {
val normalizedDomain = normalizeDomain(rawDomain)
if(!validDomain(normalizedDomain)) return failedRegistration(R.string.invalid_domain)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
pixelfedAPI = PixelfedAPI.createFromUrl(normalizedDomain)
viewModelScope.launch {
try {
val credentialsDeferred: Deferred<Application?> = async {
try {
pixelfedAPI.registerApplication(
applicationContext.getString(R.string.app_name),
"$oauthScheme://$PACKAGE_ID", SCOPE, "https://pixeldroid.org"
)
} catch (exception: Exception) {
return@async null
}
}
val nodeInfoJRD = pixelfedAPI.wellKnownNodeInfo()
val credentials = credentialsDeferred.await()
val clientId = credentials?.client_id ?: return@launch failedRegistration()
preferences.edit()
.putString("clientID", clientId)
.putString("clientSecret", credentials.client_secret)
.apply()
// c.f. https://nodeinfo.diaspora.software/protocol.html for more info
val nodeInfoSchemaUrl = nodeInfoJRD.links.firstOrNull {
it.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"
}?.href ?: return@launch failedRegistration(R.string.instance_error)
nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@launch failedRegistration()
}
}
}
private suspend fun nodeInfoSchema(
normalizedDomain: String,
clientId: String,
nodeInfoSchemaUrl: String
) = coroutineScope {
val nodeInfo: NodeInfo = try {
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@coroutineScope failedRegistration(R.string.instance_error)
}
val domain: String = try {
if (nodeInfo.hasInstanceEndpointInfo()) {
preferences.edit().putString("nodeInfo", Gson().toJson(nodeInfo)).remove("instance").apply()
nodeInfo.metadata?.config?.site?.url
} else {
val instance: Instance = try {
pixelfedAPI.instance()
} catch (exception: Exception) {
return@coroutineScope failedRegistration(R.string.instance_error)
}
preferences.edit().putString("instance", Gson().toJson(instance)).remove("nodeInfo").apply()
instance.uri
}
} catch (e: IllegalArgumentException){ null }
?: return@coroutineScope failedRegistration(R.string.instance_error)
preferences.edit()
.putString("domain", normalizeDomain(domain))
.apply()
if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.instance_not_pixelfed_warning)
_promptOauth.value = PromptOAuth(false, normalizedDomain, clientId)
} else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.api_not_enabled_dialog)
} else {
_promptOauth.value = PromptOAuth(true, normalizedDomain, clientId)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
}
}
fun authenticate(code: String?) {
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
// Get previous values from preferences
val domain = preferences.getString("domain", "") as String
val clientId = preferences.getString("clientID", "") as String
val clientSecret = preferences.getString("clientSecret", "") as String
if (code.isNullOrBlank() || domain.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
return failedRegistration(R.string.auth_failed)
}
//Successful authorization
pixelfedAPI = PixelfedAPI.createFromUrl(domain)
val gson = Gson()
val nodeInfo: NodeInfo? = gson.fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
val instance: Instance? = gson.fromJson(preferences.getString("instance", null), Instance::class.java)
viewModelScope.launch {
try {
val token = pixelfedAPI.obtainToken(
clientId, clientSecret, "$oauthScheme://$PACKAGE_ID",
SCOPE, code,
"authorization_code"
)
if (token.access_token == null) {
return@launch failedRegistration(R.string.token_error)
}
storeInstance(db, nodeInfo, instance)
storeUser(
token.access_token,
token.refresh_token,
clientId,
clientSecret,
domain
)
wipeSharedSettings()
} catch (exception: Exception) {
return@launch failedRegistration(R.string.token_error)
}
}
}
private suspend fun storeUser(accessToken: String, refreshToken: String?, clientId: String, clientSecret: String, instance: String) {
try {
val firstTime = db.userDao().getActiveUser() == null
val user = pixelfedAPI.verifyCredentials("Bearer $accessToken")
db.userDao().deActivateActiveUsers()
addUser(
db,
user,
instance,
activeUser = true,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
apiHolder.setToCurrentUser()
fetchNotifications()
_finishedLogin.value = if(firstTime) FinishedLogin.FinishedFirstTime else FinishedLogin.Finished
} catch (exception: Exception) {
return failedRegistration(R.string.verify_credentials)
}
}
// Fetch the latest notifications of this account, to avoid launching old notifications
private suspend fun fetchNotifications() {
val user = db.userDao().getActiveUser()!!
try {
val notifications = apiHolder.api!!.notifications()
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
db.notificationDao().insertAll(notifications)
} catch (exception: Exception) {
return failedRegistration(R.string.login_notifications)
}
makeNotificationChannels(
applicationContext,
user.fullHandle,
makeChannelGroupId(user)
)
}
private fun wipeSharedSettings(){
preferences.edit().clear().apply()
}
private fun failedRegistration(@StringRes message: Int = R.string.registration_failed) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, message)
when (message) {
R.string.instance_not_pixelfed_warning, R.string.api_not_enabled_dialog -> return
else -> wipeSharedSettings()
}
}
fun oauthLaunched() {
_promptOauth.value = null
}
fun oauthLaunchFailed() {
_promptOauth.value = null
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.browser_launch_failed)
}
fun dialogAckedContinueAnyways() {
_promptOauth.value = _promptOauth.value?.copy(launch = true)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
}
fun dialogNegativeButtonClicked() {
wipeSharedSettings()
_loadingState.value = LoginState(LoginState.LoadingState.Resting)
}
fun finishLogin() {
_finishedLogin.value = FinishedLogin.Finished
}
}

@ -1,62 +0,0 @@
package org.pixeldroid.app.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.AccountListItemBinding
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
class AccountListAdapter(
private val items: StateFlow<List<UserDatabaseEntity>>,
lifecycleScope: LifecycleCoroutineScope,
private val onClick: (UserDatabaseEntity?) -> Unit
) : RecyclerView.Adapter<AccountListAdapter.ViewHolder>() {
private val itemsList: MutableList<UserDatabaseEntity> = mutableListOf()
init {
lifecycleScope.launch {
items.collect {
itemsList.clear()
itemsList.addAll(it.filter { !it.isActive })
notifyDataSetChanged()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = AccountListItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.binding.root.setOnClickListener{onClick(itemsList.getOrNull(position))}
if (position == itemsList.size) {
Glide.with(holder.itemView)
.load(R.drawable.add)
.into(holder.binding.imageView)
holder.binding.accountName.setText(R.string.add_account_name)
holder.binding.accountUsername.setText(R.string.add_account_description)
return
}
val user = itemsList[position]
Glide.with(holder.itemView)
.load(user.avatar_static)
.placeholder(R.drawable.ic_default_user)
.circleCrop()
.into(holder.binding.imageView)
holder.binding.accountName.text = user.display_name
holder.binding.accountUsername.text = user.fullHandle
}
override fun getItemCount(): Int = itemsList.size + 1
class ViewHolder(val binding: AccountListItemBinding) : RecyclerView.ViewHolder(binding.root)
}

@ -1,902 +0,0 @@
package org.pixeldroid.app.main
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.GravityCompat
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.ExperimentalPagingApi
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.bumptech.glide.Glide
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.color.DynamicColors
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.navigation.NavigationView
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.iconUrl
import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityMainBinding
import org.pixeldroid.app.directmessages.DirectMessagesActivity
import org.pixeldroid.app.directmessages.DirectMessagesFragment
import org.pixeldroid.app.login.LoginActivity
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.posts.NestedScrollableHost
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.notifications.NotificationsFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds.PostFeedFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
import org.pixeldroid.app.profile.ProfileActivity
import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment
import org.pixeldroid.app.settings.SettingsActivity
import org.pixeldroid.app.settings.TutorialSettingsDialog.Companion.START_TUTORIAL
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.Tab
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Tag
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.db.updateUserInfoDb
import org.pixeldroid.app.utils.hasInternet
import org.pixeldroid.app.utils.loadDbMenuTabs
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications
import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount
import org.pixeldroid.common.dpToPx
import java.time.Instant
class MainActivity : BaseActivity() {
private lateinit var tabStored: List<Tab>
private lateinit var header: AccountHeaderView
private var user: UserDatabaseEntity? = null
private val model: MainActivityViewModel by viewModels()
companion object {
const val ADD_ACCOUNT_IDENTIFIER: Long = -13
}
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setOnExitAnimationListener {
it.remove()
}
// Workaround for dynamic colors not applying due to splash screen?
DynamicColors.applyToActivityIfAvailable(this)
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//get the currently active user
user = db.userDao().getActiveUser()
if (notificationFromOtherUser()) return
//Check if we have logged in and gotten an access token
if (user == null) {
finish()
launchActivity(LoginActivity(), firstTime = true)
} else {
sendTraceDroidStackTracesIfExist("contact@pixeldroid.org", this)
setupDrawer()
setupTabs()
val showNotification: Boolean = intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)
val showTutorial: Int = intent.getIntExtra(START_TUTORIAL, -1)
if(showNotification){
binding.viewPager.currentItem = 3
} else if(showTutorial >= 0) {
showTutorial(showTutorial)
}
if (ActivityCompat.checkSelfPermission(applicationContext,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) enablePullNotifications(this)
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun showTutorial(showTutorial: Int) {
when(showTutorial){
0 -> tutorialOnTabs(Tab.HOME_FEED)
1 -> tutorialOnTabs(Tab.CREATE_FEED)
2 -> dmTutorial()
else -> return
}
}
private fun tutorialOnTabs(tab: Tab) {
val target = (binding.tabs as? NavigationBarView)?.let{ findTab(it, tab) } ?: return //TODO tablet landscape not supported
when(tab){
Tab.HOME_FEED -> homeTutorial(target)
Tab.SEARCH_DISCOVER_FEED -> homeTutorialSearch(target)
Tab.PUBLIC_FEED -> homeTutorialPublic(target)
Tab.NOTIFICATIONS_FEED -> homeTutorialNotifications(target)
Tab.CREATE_FEED -> createTutorial(target)
else -> return
}
}
private fun dmTutorial(){
val target = (binding.tabs as? NavigationBarView)?.let{ findTab(it, Tab.DIRECT_MESSAGES) } ?: binding.mainDrawerButton ?: return //TODO tablet landscape not supported
if(target is ImageButton) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target, getString(R.string.dm_tutorial_drawer))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
dmTutorial2(null)
}
})
} else dmTutorial2(target)
}
private fun findViewWithText(root: ViewGroup, text: String?): View? {
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is TextView) {
if (child.text.toString().contains(text!!)) {
return child
}
}
if (child is ViewGroup) {
val result = findViewWithText(child, text)
if (result != null) {
return result
}
}
}
return null
}
private fun dmTutorial2(target: View?) {
lifecycleScope.launch {
var target = target ?: findViewWithText(binding.drawer as ViewGroup, getString(R.string.direct_messages))
while (target == null) {
target = findViewWithText(binding.drawer as ViewGroup, getString(R.string.direct_messages))
delay(100)
}
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target, getString(R.string.direct_messages),
getString(R.string.dm_tutorial_text))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
//tutorialOnTabs(Tab.NOTIFICATIONS_FEED)
}
})
}
}
private fun homeTutorialPublic(target: View) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target, getString(R.string.public_feed),
getString(R.string.public_feed_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
tutorialOnTabs(Tab.NOTIFICATIONS_FEED)
}
})
}
private fun homeTutorialNotifications(target: View) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target,
getString(R.string.notifications_tutorial_title),
getString(R.string.notifications_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
}
})
}
private fun homeTutorialSearch(target: View) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target,
getString(R.string.discover_tutorial_title),
getString(R.string.discover_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
tutorialOnTabs(Tab.PUBLIC_FEED)
}
})
}
private fun homeTutorial(target: View) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target,
getString(R.string.home_feed_tutorial_title),
getString(R.string.home_feed_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
tutorialOnTabs(Tab.SEARCH_DISCOVER_FEED)
}
})
}
private fun createTutorial(target: View) {
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(target, getString(R.string.create_tutorial_title),
getString(R.string.create_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
target.performClick()
lifecycleScope.launch {
var targetCamera = findViewById<View>(R.id.camera_capture_button)
while (targetCamera == null) {
targetCamera = findViewById(R.id.camera_capture_button)
delay(100)
}
TapTargetView.showFor(
this@MainActivity,
TapTarget.forView(targetCamera,
getString(R.string.create_tutorial_title_2),
getString(R.string.create_tutorial_explanation_2))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
targetCamera.performClick()
}
override fun onTargetCancel(view: TapTargetView?) {
super.onTargetCancel(view)
intent.removeExtra(START_TUTORIAL)
}
})
}
}
})
}
private fun findTab(navBar: NavigationBarView, tab: Tab): View? {
val index = tabStored.indexOf(tab)
for (i in 0 until navBar.childCount) {
val child = navBar.getChildAt(i)
if (child is ViewGroup) {
return child.getChildAt(index)
}
}
return null
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) enablePullNotifications(this)
}
// Checks if the activity was launched from a notification from another account than the
// current active one, and if so switches to that account
private fun notificationFromOtherUser(): Boolean {
val userOfNotification: String? = intent.extras?.getString(USER_NOTIFICATION_TAG)
val instanceOfNotification: String? = intent.extras?.getString(INSTANCE_NOTIFICATION_TAG)
if (userOfNotification != null && instanceOfNotification != null
&& (userOfNotification != user?.user_id
|| instanceOfNotification != user?.instance_uri)
) {
switchUser(userOfNotification, instanceOfNotification)
val newIntent = Intent(this, MainActivity::class.java)
newIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
if (intent.getBooleanExtra(SHOW_NOTIFICATION_TAG, false)) {
newIntent.putExtra(SHOW_NOTIFICATION_TAG, true)
}
finish()
startActivity(newIntent)
return true
}
return false
}
private fun setupDrawer() {
binding.mainDrawerButton?.setOnClickListener {
binding.drawer?.let { drawer -> binding.drawerLayout.openDrawer(drawer) }
}
val navigationHeader = binding.navigation?.getHeaderView(0) as? AccountHeaderView
val headerview = navigationHeader ?: AccountHeaderView(this)
navigationHeader?.onAccountHeaderSelectionViewClickListener = { _: View, _: IProfile ->
// update the arrow image within the drawer
navigationHeader!!.accountSwitcherArrow.clearAnimation()
if(binding.accountList?.isVisible == true) {
navigationHeader.accountSwitcherArrow.animate().rotation(0f).start()
} else {
navigationHeader.accountSwitcherArrow.animate().rotation(180f).start()
fun onAccountClick(user: UserDatabaseEntity?){
clickProfile(user?.user_id, user?.instance_uri, false)
}
val adapter = AccountListAdapter(model.users, lifecycleScope, ::onAccountClick)
binding.accountList?.adapter = adapter
val location = IntArray(2)
navigationHeader.getLocationOnScreen(location)
// Set the position of textView within constraintLayout2
val textViewLayoutParams = binding.accountList?.layoutParams as? ConstraintLayout.LayoutParams
textViewLayoutParams?.topMargin = location[1] + (navigationHeader as ConstraintLayout).height - 6.dpToPx(this)
binding.accountList?.layoutParams = textViewLayoutParams
}
binding.accountList?.isVisible = !(binding.accountList?.isVisible ?: false)
true
}
header = headerview.apply {
binding.drawer?.let { attachToSliderView(it) }
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean ->
val userId: String? = if (profile.identifier == ADD_ACCOUNT_IDENTIFIER) null else profile.identifier.toString()
clickProfile(userId, profile.tag?.toString(), current)
}
addProfile(ProfileSettingDrawerItem().apply {
identifier = ADD_ACCOUNT_IDENTIFIER
nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description
iconRes = R.drawable.add
}, 0)
dividerBelowHeader = false
closeDrawerOnProfileListClick = true
}
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
Glide.with(this@MainActivity)
.load(uri)
.placeholder(placeholder)
.circleCrop()
.into(imageView)
}
override fun cancel(imageView: ImageView) {
Glide.with(this@MainActivity).clear(imageView)
}
override fun placeholder(ctx: Context, tag: String?): Drawable {
if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) {
return ContextCompat.getDrawable(ctx, R.drawable.ic_default_user)!!
}
return super.placeholder(ctx, tag)
}
})
fillDrawerAccountInfo(user!!.user_id)
//after setting with the values in the db, we make sure to update the database and apply
//with the received one. This happens asynchronously.
getUpdatedAccount()
binding.drawer?.itemAdapter?.add(
primaryDrawerItem {
nameRes = R.string.direct_messages
iconRes = R.drawable.message
},
primaryDrawerItem {
nameRes = R.string.menu_account
iconRes = R.drawable.person
},
primaryDrawerItem {
nameRes = R.string.menu_settings
iconRes = R.drawable.settings
},
primaryDrawerItem {
nameRes = R.string.logout
iconRes = R.drawable.logout
},
)
binding.drawer?.onDrawerItemClickListener = { v, drawerItem, position ->
when (position) {
1 -> launchActivity(DirectMessagesActivity())
2 -> launchActivity(ProfileActivity())
3 -> launchActivity(SettingsActivity())
4 -> logOut()
}
false
}
// Closes the drawer if it is open, when we press the back button
onBackPressedDispatcher.addCallback(this) {
// Handle the back button event
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
} else {
this.isEnabled = false
super.onBackPressedDispatcher.onBackPressed()
}
}
}
private fun logOut(){
finish()
removeNotificationChannelsFromAccount(applicationContext, user)
db.runInTransaction {
db.userDao().deleteActiveUsers()
val remainingUsers = db.userDao().getAll()
if (remainingUsers.isEmpty()){
// No more users, start first-time login flow
launchActivity(LoginActivity(), firstTime = true)
} else {
val newActive = remainingUsers.first()
db.userDao().activateUser(newActive.user_id, newActive.instance_uri)
apiHolder.setToCurrentUser()
// Relaunch the app
launchActivity(MainActivity(), firstTime = true)
}
}
}
private fun getUpdatedAccount() {
if (hasInternet(applicationContext)) {
lifecycleScope.launchWhenCreated {
try {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val account = api.verifyCredentials()
updateUserInfoDb(db, account)
val show = api.getSettings().common.media.always_show_cw
launch {
PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit().putBoolean("always_show_nsfw", show).commit()
}
//No need to update drawer account info here, the ViewModel listens to db updates
} catch (exception: Exception) {
Log.e("ACCOUNT UPDATE:", exception.toString())
}
}
}
}
//called when switching profiles, or when clicking on current profile
@Suppress("SameReturnValue")
private fun clickProfile(id: String?, instance: String?, current: Boolean): Boolean {
if(current){
launchActivity(ProfileActivity())
return false
}
//Clicked on add new account
if(id == null || instance == null){
launchActivity(LoginActivity())
return false
}
switchUser(id, instance)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
finish()
startActivity(intent)
return false
}
private fun switchUser(userId: String, instance_uri: String) {
db.runInTransaction{
db.userDao().deActivateActiveUsers()
db.userDao().activateUser(userId, instance_uri)
apiHolder.setToCurrentUser()
}
}
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem()
.apply {
isSelectable = false
isIconTinted = true
}
.apply(block)
}
private fun fillDrawerAccountInfo(account: String) {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
model.users.collect { list ->
val users = list.toMutableList()
users.sortWith { l, r ->
when {
l.isActive && !r.isActive -> -1
r.isActive && !l.isActive -> 1
else -> 0
}
}
val profiles: MutableList<IProfile> = users.map { user ->
ProfileDrawerItem().apply {
isSelected = user.isActive
nameText = user.display_name
iconUrl = user.avatar_static
isNameShown = true
identifier = user.user_id.toLong()
descriptionText = user.fullHandle
tag = user.instance_uri
}
}.toMutableList()
// reuse the already existing "add account" item
header.profiles.orEmpty()
.filter { it.identifier == ADD_ACCOUNT_IDENTIFIER }
.take(1)
.forEach { profiles.add(it) }
header.clear()
header.profiles = profiles
header.setActiveProfile(account.toLong())
}
}
}
}
/**
* Use reflection to make it a bit harder to swipe between tabs
*/
private fun ViewPager2.reduceDragSensitivity() {
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
recyclerViewField.isAccessible = true
val recyclerView = recyclerViewField.get(this) as RecyclerView
val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop")
touchSlopField.isAccessible = true
val touchSlop = touchSlopField.get(recyclerView) as Int
touchSlopField.set(recyclerView, touchSlop*NestedScrollableHost.touchSlopModifier)
}
private fun NavigationView.unSelectAll() {
for (i in 0 until menu.size()) {
val menuItem = menu.getItem(i)
menuItem.isChecked = false
}
}
private fun MenuItem.itemPos(): Int? {
return when(itemId){
R.id.page_1 -> 0
R.id.page_2 -> 1
R.id.page_3 -> 2
R.id.page_4 -> 3
R.id.page_5 -> 4
else -> null
}
}
private fun reclick(item: MenuItem) {
item.itemPos()?.let { position ->
val page =
//No clue why this works but it does. F to pay respects
supportFragmentManager.findFragmentByTag("f$position")
(page as? CachedFeedFragment<*>)?.onTabReClicked()
}
}
@OptIn(ExperimentalPagingApi::class)
private fun setupTabs() {
val tabsCheckedDbEntry = db.tabsDao().getTabsChecked(user!!.user_id, user!!.instance_uri)
val pageIds = listOf(R.id.page_1, R.id.page_2, R.id.page_3, R.id.page_4, R.id.page_5)
fun Tab.getFragment(): (() -> Fragment) {
return when (this) {
Tab.HOME_FEED -> { {
PostFeedFragment<HomeStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", true) }
}
} }
Tab.SEARCH_DISCOVER_FEED -> { { SearchDiscoverFragment() } }
Tab.CREATE_FEED -> { { CameraFragment().apply {
arguments = Bundle().apply { putInt(START_TUTORIAL, intent.getIntExtra(START_TUTORIAL, -1)) }
} } }
Tab.NOTIFICATIONS_FEED -> { { NotificationsFragment() } }
Tab.PUBLIC_FEED -> { {
PostFeedFragment<PublicFeedStatusDatabaseEntity>()
.apply {
arguments = Bundle().apply { putBoolean("home", false) }
}
} }
Tab.DIRECT_MESSAGES -> { {
DirectMessagesFragment()
} }
Tab.HASHTAG_FEED -> { {
UncachedPostsFragment()
.apply {
arguments = Bundle().apply {
putString(Tag.HASHTAG_TAG, this@getFragment.filter)
}
}
} }
}
}
val (tabs, hashtagIndices) = if (tabsCheckedDbEntry.isEmpty()) {
// Default menu
Pair(
Tab.defaultTabs,
Tab.defaultTabs.map { 0 }
)
} else {
Pair(
// Get current menu visibility and order from settings
loadDbMenuTabs(tabsCheckedDbEntry).filter { it.second }.map { it.first },
// Get all hashtag feed indices
db.tabsDao().getTabsChecked(user!!.user_id, user!!.instance_uri).filter {
it.checked
}.map {
if (Tab.fromName(it.tab) == Tab.HASHTAG_FEED) {
it.index
} else {
0
}
}
)
}
tabStored = tabs
val bottomNavigationMenu: Menu? = (binding.tabs as? NavigationBarView)?.menu?.apply {
clear()
}
?: binding.navigation?.menu?.apply {
if(tabs.contains(Tab.DIRECT_MESSAGES)) removeGroup(R.id.dmNavigationGroup)
}
val user = db.userDao().getActiveUser()!!
hashtagIndices.zip(tabs).zip(pageIds).forEach { (indexPageId, pageId) ->
val index = indexPageId.first
val tabId = indexPageId.second
with(bottomNavigationMenu?.add(R.id.tabsId, pageId, 1, tabId.toLanguageString(this@MainActivity, db, index, true))) {
val tabIcon = tabId.getDrawable(this@MainActivity)
if (tabIcon != null) {
this?.icon = tabIcon
}
}
}
val tabArray: List<() -> Fragment> = tabs.map { it.getFragment() }
binding.viewPager.reduceDragSensitivity()
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
override fun createFragment(position: Int): Fragment {
return tabArray[position]()
}
override fun getItemCount(): Int {
return tabArray.size
}
}
val notificationId = tabs.zip(pageIds).find {
it.first == Tab.NOTIFICATIONS_FEED
}?.second
fun doAtPageId(pageId: Int): Int {
if (notificationId != null && pageId == notificationId) {
setNotificationBadge(false)
}
return pageId
}
binding.viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val selected = when(position){
0 -> doAtPageId(R.id.page_1)
1 -> doAtPageId(R.id.page_2)
2 -> doAtPageId(R.id.page_3)
3 -> doAtPageId(R.id.page_4)
4 -> doAtPageId(R.id.page_5)
else -> null
}
if (selected != null) {
// Disable and re-enable reselected listener so that it's not triggered by this
(binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(null)
(binding.tabs as? NavigationBarView)?.selectedItemId = selected
(binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(::reclick)
binding.navigation?.unSelectAll()
binding.navigation?.menu?.getItem(position)?.setChecked(true)
}
super.onPageSelected(position)
}
})
fun MenuItem.buttonPos() {
when(itemId){
R.id.dms -> launchActivity(DirectMessagesActivity())
R.id.my_profile -> launchActivity(ProfileActivity())
R.id.settings -> launchActivity(SettingsActivity())
R.id.log_out -> logOut()
}
}
(binding.tabs as? NavigationBarView)?.setOnItemSelectedListener { item ->
item.itemPos()?.let {
binding.viewPager.currentItem = it
true
} ?: false
}
(binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(::reclick)
binding.navigation?.setNavigationItemSelectedListener { item ->
if (binding.navigation?.menu?.children?.find { it.itemId == item.itemId }?.isChecked == true) {
reclick(item)
} else {
item.itemPos()?.let {
binding.navigation?.unSelectAll()
item.isChecked = true
binding.viewPager.currentItem = it
true
} ?: item.buttonPos()
}
true
}
// Fetch one notification to show a badge if there are new notifications
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
user?.let {
val lastNotification = db.notificationDao().latestNotification(it.user_id, it.instance_uri)
try {
val notification: List<Notification>? = apiHolder.api?.notifications(
min_id = lastNotification?.id,
limit = "20"
)
val filtered = notification?.filter { notification ->
lastNotification == null || (notification.created_at
?: Instant.MIN) > (lastNotification.created_at ?: Instant.MIN)
}
val numberOfNewNotifications = if((filtered?.size ?: 20) >= 20) null else filtered?.size
if(filtered?.isNotEmpty() == true ) setNotificationBadge(true, numberOfNewNotifications)
} catch (exception: Exception) {
return@repeatOnLifecycle
}
}
}
}
}
private fun setNotificationBadge(show: Boolean, count: Int? = null) {
//TODO add badge to NavigationView... not implemented yet: https://github.com/material-components/material-components-android/issues/2860
if(show){
val badge = (binding.tabs as? NavigationBarView)?.getOrCreateBadge(R.id.page_4)
if (count != null) badge?.number = count
}
else (binding.tabs as? NavigationBarView)?.removeBadge(R.id.page_4)
}
/**
* Launches the given activity and put it as the current one
* @param firstTime to true means the task history will be reset (as if the app were
* launched anew into this activity)
*/
private fun launchActivity(activity: AppCompatActivity, firstTime: Boolean = false) {
val intent = Intent(this, activity::class.java)
if(firstTime){
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
}
}

@ -1,40 +0,0 @@
package org.pixeldroid.app.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
private val db: AppDatabase
): ViewModel() {
// Mutable state flow that will be used internally in the ViewModel, empty list is given as initial value.
private val _users = MutableStateFlow(emptyList<UserDatabaseEntity>())
// Immutable state flow exposed to UI
val users = _users.asStateFlow()
init {
getUsers()
}
private fun getUsers() {
viewModelScope.launch {
db.userDao().getAllFlow().flowOn(Dispatchers.IO)
.collect { users: List<UserDatabaseEntity> ->
_users.update { users }
}
}
}
}

@ -1,193 +1,53 @@
package org.pixeldroid.app.postCreation
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.lifecycle.lifecycleScope
import android.os.*
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.appbar.MaterialToolbar
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityPostCreationBinding
import org.pixeldroid.app.settings.TutorialSettingsDialog.Companion.START_TUTORIAL
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
const val TAG = "Post Creation Activity"
class PostCreationActivity : BaseActivity() {
companion object {
internal const val POST_DESCRIPTION = "post_description"
internal const val PICTURE_DESCRIPTIONS = "picture_descriptions"
internal const val PICTURE_DESCRIPTION = "picture_description"
internal const val POST_REDRAFT = "post_redraft"
internal const val POST_NSFW = "post_nsfw"
internal const val TEMP_FILES = "temp_files"
fun intentForUris(context: Context, uris: List<Uri>) =
Intent(Intent.ACTION_SEND_MULTIPLE).apply {
// Pass downloaded images to new post creation activity
putParcelableArrayListExtra(
Intent.EXTRA_STREAM, ArrayList(uris)
)
uris.forEach {
// Why are we using ClipData in addition to parcelableArrayListExtra here?
// Because the FLAG_GRANT_READ_URI_PERMISSION needs to be applied to the URIs, and
// for some reason it doesn't get applied to all of them when not using ClipData
if (clipData == null) {
clipData = ClipData("", emptyArray(), ClipData.Item(it))
} else {
clipData!!.addItem(ClipData.Item(it))
}
}
setClass(context, PostCreationActivity::class.java)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
}
private var user: UserDatabaseEntity? = null
private lateinit var instance: InstanceDatabaseEntity
private lateinit var binding: ActivityPostCreationBinding
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
user = db.userDao().getActiveUser()
instance = user?.run {
db.instanceDao().getAll().first { instanceDatabaseEntity ->
instanceDatabaseEntity.uri.contains(instance_uri)
}
} ?: InstanceDatabaseEntity("", "")
binding = ActivityPostCreationBinding.inflate(layoutInflater)
setContentView(binding.root)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.postCreationContainer) as NavHostFragment
navController = navHostFragment.navController
navController.setGraph(R.navigation.post_creation_graph)
if(intent.getBooleanExtra(START_TUTORIAL, false)) lifecycleScope.launch {
var targetCamera = findViewById<View>(R.id.toggleStoryPost)
while (targetCamera == null) {
targetCamera = findViewById(R.id.toggleStoryPost)
delay(100)
}
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forView(targetCamera,
getString(R.string.story_tutorial_title),
getString(R.string.story_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
findViewById<View>(R.id.buttonStory)?.performClick()
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forView(findViewById(R.id.editPhotoButton),
getString(R.string.edit_tutorial_title),
getString(R.string.edit_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
findViewById<View>(R.id.editPhotoButton)?.performClick()
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forView(findViewById(R.id.tv_caption),
getString(R.string.media_description_tutorial_title),
getString(R.string.media_description_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
findViewById<View>(R.id.tv_caption)?.performClick()
lifecycleScope.launch {
delay(1000)
var tv_caption = findViewById<View>(R.id.tv_caption)
while (tv_caption == null || tv_caption.visibility != View.VISIBLE) {
tv_caption = findViewById(R.id.tv_caption)
delay(100)
}
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forView(findViewById(R.id.post_creation_next_button),
getString(
R.string.picture_tutorial_title
),
getString(R.string.picture_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
findViewById<View>(R.id.post_creation_next_button)?.performClick()
showAccountChooser()
}
})
}
}
})
}
})
}
})
}
}
private fun showAccountChooser() {
lifecycleScope.launch {
var toolbar = findViewById<View>(R.id.top_bar) as? MaterialToolbar
while (toolbar == null) {
toolbar = findViewById(R.id.top_bar) as? MaterialToolbar
delay(100)
}
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forToolbarMenuItem(
toolbar,
R.id.action_switch_accounts,
getString(R.string.switch_accounts_tutorial_title),
getString(R.string.switch_accounts_tutorial_explanation)
)
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
showPostButton()
}
})
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
private fun showPostButton() {
TapTargetView.showFor(
this@PostCreationActivity, // `this` is an Activity
TapTarget.forView(findViewById(R.id.post_submission_send_button),
getString(R.string.post_button_tutorial_title),
getString(R.string.post_button_tutorial_explanation))
.transparentTarget(true)
.targetRadius(60),
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
findViewById<View>(R.id.post_creation_next_button)?.performClick()
}
})
}
override fun onSupportNavigateUp() = navController.navigateUp() || super.onSupportNavigateUp()
}

@ -33,24 +33,29 @@ import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.FragmentPostCreationBinding
import org.pixeldroid.app.postCreation.camera.CameraActivity
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.postCreation.carousel.CarouselItem
import org.pixeldroid.app.utils.BaseFragment
import org.pixeldroid.app.utils.bindingLifecycleAware
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.fileExtension
import org.pixeldroid.app.utils.getMimeType
import org.pixeldroid.media_editor.common.PICTURE_POSITION
import org.pixeldroid.media_editor.common.PICTURE_URI
import org.pixeldroid.media_editor.photoEdit.PhotoEditActivity
import org.pixeldroid.media_editor.videoEdit.VideoEditActivity
import org.pixeldroid.media_editor.photoEdit.VideoEditActivity
import java.io.File
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Locale
class PostCreationFragment : BaseFragment() {
private var user: UserDatabaseEntity? = null
private var instance: InstanceDatabaseEntity = InstanceDatabaseEntity("", "")
private var binding: FragmentPostCreationBinding by bindingLifecycleAware()
private val model: PostCreationViewModel by activityViewModels()
private lateinit var model: PostCreationViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@ -67,16 +72,30 @@ class PostCreationFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val user = db.userDao().getActiveUser()
user = db.userDao().getActiveUser()
val instance = user?.run {
db.instanceDao().getInstance(instance_uri)
instance = user?.run {
db.instanceDao().getAll().first { instanceDatabaseEntity ->
instanceDatabaseEntity.uri.contains(instance_uri)
}
} ?: InstanceDatabaseEntity("", "")
model.getPhotoData().observe(viewLifecycleOwner) { newPhotoData: MutableList<PhotoData>? ->
val _model: PostCreationViewModel by activityViewModels {
PostCreationViewModelFactory(
requireActivity().application,
requireActivity().intent.clipData!!,
instance,
requireActivity().intent.getStringExtra(PostCreationActivity.PICTURE_DESCRIPTION),
requireActivity().intent.getBooleanExtra(PostCreationActivity.POST_NSFW, false),
requireActivity().intent.getBooleanExtra(CameraFragment.CAMERA_ACTIVITY_STORY, false),
)
}
model = _model
model.getPhotoData().observe(viewLifecycleOwner) { newPhotoData ->
// update UI
binding.carousel.addData(
newPhotoData.orEmpty().map {
newPhotoData.map {
CarouselItem(
it.imageUri, it.imageDescription, it.video,
it.videoEncodeProgress, it.videoEncodeStabilizationFirstPass,
@ -84,7 +103,7 @@ class PostCreationFragment : BaseFragment() {
)
}
)
binding.postCreationNextButton.isEnabled = newPhotoData?.isNotEmpty() ?: false
binding.postCreationNextButton.isEnabled = newPhotoData.isNotEmpty()
}
lifecycleScope.launch {
@ -208,9 +227,10 @@ class PostCreationFragment : BaseFragment() {
}
private val addPhotoResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uris = result.data?.extras?.getParcelableArrayList<Uri>(Intent.EXTRA_STREAM)
if (result.resultCode == Activity.RESULT_OK && uris != null) {
model.setImages(model.addPossibleImages(uris, emptyList()))
if (result.resultCode == Activity.RESULT_OK && result.data?.clipData != null) {
result.data?.clipData?.let {
model.setImages(model.addPossibleImages(it))
}
} else if (result.resultCode != Activity.RESULT_CANCELED) {
Toast.makeText(requireActivity(), R.string.add_images_error, Toast.LENGTH_SHORT).show()
}
@ -308,7 +328,7 @@ class PostCreationFragment : BaseFragment() {
ActivityResultContracts.StartActivityForResult()){
result: ActivityResult? ->
if (result?.resultCode == Activity.RESULT_OK && result.data != null) {
val position: Int = result.data!!.getIntExtra(PICTURE_POSITION, 0)
val position: Int = result.data!!.getIntExtra(PhotoEditActivity.PICTURE_POSITION, 0)
model.modifyAt(position, result.data!!)
?: Toast.makeText(requireActivity(), R.string.error_editing, Toast.LENGTH_SHORT).show()
} else if(result?.resultCode != Activity.RESULT_CANCELED){
@ -321,8 +341,8 @@ class PostCreationFragment : BaseFragment() {
requireActivity(),
if (model.getPhotoData().value!![position].video) VideoEditActivity::class.java else PhotoEditActivity::class.java
)
.putExtra(PICTURE_URI, model.getPhotoData().value!![position].imageUri)
.putExtra(PICTURE_POSITION, position)
.putExtra(PhotoEditActivity.PICTURE_URI, model.getPhotoData().value!![position].imageUri)
.putExtra(PhotoEditActivity.PICTURE_POSITION, position)
editResultContract.launch(intent)
}

@ -1,6 +1,7 @@
package org.pixeldroid.app.postCreation
import android.content.Context
import android.app.Application
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
@ -11,16 +12,15 @@ import android.widget.Toast
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.jarsilio.android.scrambler.exceptions.UnsupportedFileFormatException
import com.jarsilio.android.scrambler.stripMetadata
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
@ -31,27 +31,37 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import okhttp3.MultipartBody
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.fileExtension
import org.pixeldroid.app.utils.getMimeType
import org.pixeldroid.media_editor.common.PICTURE_URI
import org.pixeldroid.media_editor.videoEdit.VideoEditActivity
import org.pixeldroid.media_editor.photoEdit.VideoEditActivity
import retrofit2.HttpException
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.net.URI
import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.collections.MutableList
import kotlin.collections.MutableMap
import kotlin.collections.arrayListOf
import kotlin.collections.forEach
import kotlin.collections.get
import kotlin.collections.getOrNull
import kotlin.collections.indexOfFirst
import kotlin.collections.mutableListOf
import kotlin.collections.mutableMapOf
import kotlin.collections.plus
import kotlin.collections.set
import kotlin.collections.toMutableList
import kotlin.math.ceil
const val TAG = "Post Creation ViewModel"
// Models the UI state for the PostCreationActivity
data class PostCreationActivityUiState(
@ -98,56 +108,35 @@ data class PhotoData(
var videoEncodeError: Boolean = false,
) : Parcelable
@HiltViewModel
class PostCreationViewModel @Inject constructor(
private val state: SavedStateHandle,
@ApplicationContext private val applicationContext: Context,
db: AppDatabase,
): ViewModel() {
class PostCreationViewModel(
application: Application,
clipdata: ClipData? = null,
val instance: InstanceDatabaseEntity? = null,
existingDescription: String? = null,
existingNSFW: Boolean = false,
storyCreation: Boolean = false,
) : AndroidViewModel(application) {
private var storyPhotoDataBackup: MutableList<PhotoData>? = null
private val photoData: MutableLiveData<MutableList<PhotoData>> by lazy {
//FIXME We should be able to access the Intent action somehow, to determine if there are
// 1 or multiple Uris instead of relying on the ClassCastException
// This should not work like this (reading its source code, get() function should return null
// if it's the wrong type but instead throws ClassCastException).
// Lucky for us that it does though: we first try to get a single Uri (which we could be
// getting from a share of a single picture to the app), when the cast to Uri fails
// we try to get a list of Uris instead (casting ourselves from Parcelable as suggested
// in get() documentation)
val uris = try {
val singleUri: Uri? = state[Intent.EXTRA_STREAM]
listOfNotNull(singleUri)
} catch (e: ClassCastException) {
state.get<ArrayList<Parcelable>>(Intent.EXTRA_STREAM)?.map { it as Uri }
MutableLiveData<MutableList<PhotoData>>().also {
it.value = clipdata?.let { it1 -> addPossibleImages(it1, mutableListOf()) }
}
MutableLiveData<MutableList<PhotoData>>(
addPossibleImages(
uris,
state.get<ArrayList<String>>(PostCreationActivity.PICTURE_DESCRIPTIONS),
previousList = mutableListOf()
)
)
}
private val instance = db.instanceDao().getActiveInstance()
@Inject
lateinit var apiHolder: PixelfedAPIHolder
private val _uiState: MutableStateFlow<PostCreationActivityUiState>
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
val sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(applicationContext)
PreferenceManager.getDefaultSharedPreferences(application)
val templateDescription = sharedPreferences.getString("prefill_description", "") ?: ""
val storyCreation: Boolean = state[CameraFragment.CAMERA_ACTIVITY_STORY] ?: false
_uiState = MutableStateFlow(PostCreationActivityUiState(
newPostDescriptionText = state[PostCreationActivity.POST_DESCRIPTION] ?: templateDescription,
nsfw = state[PostCreationActivity.POST_NSFW] ?: false,
newPostDescriptionText = existingDescription ?: templateDescription,
nsfw = existingNSFW,
maxEntries = if(storyCreation) 1 else instance?.albumLimit,
storyCreation = storyCreation
))
@ -172,41 +161,32 @@ class PostCreationViewModel @Inject constructor(
fun getPhotoData(): LiveData<MutableList<PhotoData>> = photoData
/**
* Will add as many images as possible to [photoData], from the [uris], and if
* ([photoData].size + [uris].size) > uiState.value.maxEntries then it will only add as many images
* Will add as many images as possible to [photoData], from the [clipData], and if
* ([photoData].size + [clipData].itemCount) > uiState.value.maxEntries then it will only add as many images
* as are legal (if any) and a dialog will be shown to the user alerting them of this fact.
*/
fun addPossibleImages(
uris: List<Uri>?,
descriptions: List<String>?,
previousList: MutableList<PhotoData>? = photoData.value,
): MutableList<PhotoData> {
fun addPossibleImages(clipData: ClipData, previousList: MutableList<PhotoData>? = photoData.value): MutableList<PhotoData> {
val dataToAdd: ArrayList<PhotoData> = arrayListOf()
var count = uris?.size ?: 0
uiState.value.maxEntries?.let { maxEntries ->
if(count + (previousList?.size ?: 0) > maxEntries){
var count = clipData.itemCount
uiState.value.maxEntries?.let {
if(count + (previousList?.size ?: 0) > it){
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = applicationContext.getString(R.string.total_exceeds_album_limit).format(maxEntries))
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.total_exceeds_album_limit).format(it))
}
count = count.coerceAtMost(maxEntries - (previousList?.size ?: 0))
count = count.coerceAtMost(it - (previousList?.size ?: 0))
}
if (count + (previousList?.size ?: 0) >= maxEntries) {
if (count + (previousList?.size ?: 0) >= it) {
// Disable buttons to add more images
_uiState.update { currentUiState ->
currentUiState.copy(addPhotoButtonEnabled = false)
}
}
for ((i, uri) in uris.orEmpty().withIndex()) {
val sizeAndVideoPair: Pair<Long, Boolean> =
getSizeAndVideoValidate(uri, (previousList?.size ?: 0) + dataToAdd.size + 1)
dataToAdd.add(
PhotoData(
imageUri = uri,
size = sizeAndVideoPair.first,
video = sizeAndVideoPair.second,
imageDescription = descriptions?.getOrNull(i)
)
)
for (i in 0 until count) {
clipData.getItemAt(i).let {
val sizeAndVideoPair: Pair<Long, Boolean> =
getSizeAndVideoValidate(it.uri, (previousList?.size ?: 0) + dataToAdd.size + 1)
dataToAdd.add(PhotoData(imageUri = it.uri, size = sizeAndVideoPair.first, video = sizeAndVideoPair.second, imageDescription = it.text?.toString()))
}
}
}
@ -224,7 +204,7 @@ class PostCreationViewModel @Inject constructor(
private fun getSizeAndVideoValidate(uri: Uri, editPosition: Int): Pair<Long, Boolean> {
val size: Long =
if (uri.scheme =="content") {
applicationContext.contentResolver.query(uri, null, null, null, null)
getApplication<PixelDroidApplication>().contentResolver.query(uri, null, null, null, null)
?.use { cursor ->
/* Get the column indexes of the data in the Cursor,
* move to the first row in the Cursor, get the data,
@ -241,12 +221,12 @@ class PostCreationViewModel @Inject constructor(
}
val sizeInkBytes = ceil(size.toDouble() / 1000).toLong()
val type = uri.getMimeType(applicationContext.contentResolver)
val type = uri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
val isVideo = type.startsWith("video/")
if (isVideo && !instance!!.videoEnabled) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = applicationContext.getString(R.string.video_not_supported))
currentUiState.copy(userMessage = getApplication<PixelDroidApplication>().getString(R.string.video_not_supported))
}
}
@ -255,7 +235,7 @@ class PostCreationViewModel @Inject constructor(
val maxSize = if (isVideo) instance.maxVideoSize else instance.maxPhotoSize
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = applicationContext.getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)
userMessage = getApplication<PixelDroidApplication>().getString(R.string.size_exceeds_instance_limit, editPosition, sizeInkBytes, maxSize)
)
}
}
@ -292,7 +272,7 @@ class PostCreationViewModel @Inject constructor(
videoEncodeComplete = false
VideoEditActivity.startEncoding(imageUri, null, it,
context = applicationContext,
context = getApplication<PixelDroidApplication>(),
registerNewFFmpegSession = ::registerNewFFmpegSession,
trackTempFile = ::trackTempFile,
videoEncodeProgress = ::videoEncodeProgress
@ -300,7 +280,7 @@ class PostCreationViewModel @Inject constructor(
}
}
} else {
imageUri = data.getStringExtra(PICTURE_URI)!!.toUri()
imageUri = data.getStringExtra(org.pixeldroid.media_editor.photoEdit.PhotoEditActivity.PICTURE_URI)!!.toUri()
val (imageSize, imageVideo) = getSizeAndVideoValidate(imageUri, position)
size = imageSize
video = imageVideo
@ -407,17 +387,17 @@ class PostCreationViewModel @Inject constructor(
}
for (data: PhotoData in getPhotoData().value ?: emptyList()) {
val extension = data.imageUri.fileExtension(applicationContext.contentResolver)
val extension = data.imageUri.fileExtension(getApplication<PixelDroidApplication>().contentResolver)
val strippedImage = File.createTempFile("temp_img", ".$extension", applicationContext.cacheDir)
val strippedImage = File.createTempFile("temp_img", ".$extension", getApplication<PixelDroidApplication>().cacheDir)
val imageUri = data.imageUri
val (strippedOrNot, size) = try {
val orientation = ExifInterface(applicationContext.contentResolver.openInputStream(imageUri)!!).getAttributeInt(
val orientation = ExifInterface(getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!).getAttributeInt(
ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
stripMetadata(imageUri, strippedImage, applicationContext.contentResolver)
stripMetadata(imageUri, strippedImage, getApplication<PixelDroidApplication>().contentResolver)
// Restore EXIF orientation
val exifInterface = ExifInterface(strippedImage)
@ -429,11 +409,11 @@ class PostCreationViewModel @Inject constructor(
strippedImage.delete()
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
val imageInputStream = try {
applicationContext.contentResolver.openInputStream(imageUri)!!
getApplication<PixelDroidApplication>().contentResolver.openInputStream(imageUri)!!
} catch (e: FileNotFoundException){
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = applicationContext.getString(R.string.file_not_found,
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
@ -445,14 +425,14 @@ class PostCreationViewModel @Inject constructor(
if(imageUri != data.imageUri) File(URI(imageUri.toString())).delete()
_uiState.update { currentUiState ->
currentUiState.copy(
userMessage = applicationContext.getString(R.string.file_not_found,
userMessage = getApplication<PixelDroidApplication>().getString(R.string.file_not_found,
data.imageUri)
)
}
return
}
val type = data.imageUri.getMimeType(applicationContext.contentResolver)
val type = data.imageUri.getMimeType(getApplication<PixelDroidApplication>().contentResolver)
val imagePart = ProgressRequestBody(strippedOrNot, size, type)
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
@ -502,7 +482,7 @@ class PostCreationViewModel @Inject constructor(
currentUiState.copy(
uploadErrorVisible = true,
uploadErrorExplanationText = if(e is HttpException){
applicationContext.getString(R.string.upload_error, e.code())
getApplication<PixelDroidApplication>().getString(R.string.upload_error, e.code())
} else "",
uploadErrorExplanationVisible = e is HttpException,
)
@ -568,14 +548,14 @@ class PostCreationViewModel @Inject constructor(
sensitive = nsfw
)
}
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_success),
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_success),
Toast.LENGTH_SHORT).show()
val intent = Intent(applicationContext, MainActivity::class.java)
val intent = Intent(getApplication(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
//TODO make the activity launch this instead (and surrounding toasts too)
applicationContext.startActivity(intent)
getApplication<PixelDroidApplication>().startActivity(intent)
} catch (exception: IOException) {
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_error),
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_error),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.toString())
_uiState.update { currentUiState ->
@ -584,7 +564,7 @@ class PostCreationViewModel @Inject constructor(
)
}
} catch (exception: HttpException) {
Toast.makeText(applicationContext, applicationContext.getString(R.string.upload_post_failed),
Toast.makeText(getApplication(), getApplication<PixelDroidApplication>().getString(R.string.upload_post_failed),
Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.response().toString() + exception.message().toString())
_uiState.update { currentUiState ->
@ -629,7 +609,7 @@ class PostCreationViewModel @Inject constructor(
//Show message saying extraneous pictures were removed but can be restored
newUiState = newUiState.copy(
userMessage = applicationContext.getString(R.string.extraneous_pictures_stories)
userMessage = getApplication<PixelDroidApplication>().getString(R.string.extraneous_pictures_stories)
)
}
// Restore if backup not null and first value is unchanged
@ -649,4 +629,10 @@ class PostCreationViewModel @Inject constructor(
fun updateStoryReactions(checked: Boolean) { _uiState.update { it.copy(storyReactions = checked) } }
fun updateStoryReplies(checked: Boolean) { _uiState.update { it.copy(storyReplies = checked) } }
}
}
class PostCreationViewModelFactory(val application: Application, val clipdata: ClipData, val instance: InstanceDatabaseEntity, val existingDescription: String?, val existingNSFW: Boolean, val storyCreation: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java, ClipData::class.java, InstanceDatabaseEntity::class.java, String::class.java, Boolean::class.java, Boolean::class.java).newInstance(application, clipdata, instance, existingDescription, existingNSFW, storyCreation)
}
}

@ -38,7 +38,7 @@ class PostSubmissionFragment : BaseFragment() {
private lateinit var instance: InstanceDatabaseEntity
private var binding: FragmentPostSubmissionBinding by bindingLifecycleAware()
private val model: PostCreationViewModel by activityViewModels()
private lateinit var model: PostCreationViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@ -60,9 +60,23 @@ class PostSubmissionFragment : BaseFragment() {
accounts = db.userDao().getAll()
instance = user?.run {
db.instanceDao().getInstance(instance_uri)
db.instanceDao().getAll().first { instanceDatabaseEntity ->
instanceDatabaseEntity.uri.contains(instance_uri)
}
} ?: InstanceDatabaseEntity("", "")
val _model: PostCreationViewModel by activityViewModels {
PostCreationViewModelFactory(
requireActivity().application,
requireActivity().intent.clipData!!,
instance,
requireActivity().intent.getStringExtra(PostCreationActivity.PICTURE_DESCRIPTION),
requireActivity().intent.getBooleanExtra(PostCreationActivity.POST_NSFW, false),
requireActivity().intent.getBooleanExtra(CameraFragment.CAMERA_ACTIVITY_STORY, false)
)
}
model = _model
// Display the values from the view model
binding.nsfwSwitch.isChecked = model.uiState.value.nsfw
binding.newPostDescriptionInputField.setText(model.uiState.value.newPostDescriptionText)

@ -3,7 +3,7 @@ package org.pixeldroid.app.postCreation.camera
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityCameraBinding
import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY
@ -32,7 +32,7 @@ class CameraActivity : BaseActivity() {
// If this CameraActivity wasn't started from the shortcut,
// tell the fragment it's in an activity (so that it sends back the result instead of
// starting a new post creation process)
if (intent.action != Intent.ACTION_VIEW) {
if (intent.action != "android.intent.action.VIEW") {
val arguments = Bundle()
arguments.putBoolean(CAMERA_ACTIVITY, true)
arguments.putBoolean(CAMERA_ACTIVITY_STORY, story)
@ -47,7 +47,7 @@ class CameraActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// If this CameraActivity wasn't started from the shortcut, behave as usual
if (intent.action != Intent.ACTION_VIEW) return super.onOptionsItemSelected(item)
if (intent.action != "android.intent.action.VIEW") return super.onOptionsItemSelected(item)
// Else, start a new MainActivity when "going back" on this activity
when (item.itemId) {

@ -2,6 +2,7 @@ package org.pixeldroid.app.postCreation.camera
import android.Manifest
import android.app.Activity
import android.content.ClipData
import android.content.ContentUris
import android.content.Intent
import android.content.pm.PackageManager
@ -37,8 +38,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.FragmentCameraBinding
import org.pixeldroid.app.postCreation.PostCreationActivity
import org.pixeldroid.app.settings.TutorialSettingsDialog.Companion.START_TUTORIAL
import org.pixeldroid.app.utils.BaseFragment
import org.pixeldroid.app.utils.bindingLifecycleAware
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@ -59,7 +60,7 @@ class CameraFragment : BaseFragment() {
private val cameraLifecycleOwner = CameraLifecycleOwner()
private lateinit var binding: FragmentCameraBinding
private var binding: FragmentCameraBinding by bindingLifecycleAware()
private var displayId: Int = -1
private var lensFacing: Int = CameraSelector.LENS_FACING_BACK
@ -69,7 +70,6 @@ class CameraFragment : BaseFragment() {
private var inActivity by Delegates.notNull<Boolean>()
private var addToStory by Delegates.notNull<Boolean>()
private var tutorial by Delegates.notNull<Int>()
private var filePermissionDialogLaunched: Boolean = false
@ -92,8 +92,6 @@ class CameraFragment : BaseFragment() {
inActivity = arguments?.getBoolean(CAMERA_ACTIVITY) ?: false
addToStory = arguments?.getBoolean(CAMERA_ACTIVITY_STORY) ?: false
tutorial = arguments?.getInt(START_TUTORIAL) ?: -1
binding = FragmentCameraBinding.inflate(layoutInflater)
return binding.root
@ -329,7 +327,7 @@ class CameraFragment : BaseFragment() {
}
private fun setupUploadImage() {
val videoEnabled: Boolean = db.instanceDao().getActiveInstance().videoEnabled
val videoEnabled: Boolean = db.instanceDao().getInstance(db.userDao().getActiveUser()!!.instance_uri).videoEnabled
var mimeTypes: Array<String> = arrayOf("image/*")
if(videoEnabled) mimeTypes += "video/*"
@ -452,7 +450,21 @@ class CameraFragment : BaseFragment() {
private fun startAlbumCreation(uris: ArrayList<String>) {
val intent = PostCreationActivity.intentForUris(requireContext(), uris.map { it.toUri() })
val intent = Intent(requireActivity(), PostCreationActivity::class.java)
.apply {
uris.forEach{
//Why are we using ClipData here? Because the FLAG_GRANT_READ_URI_PERMISSION
//needs to be applied to the URIs, and this flag only applies to the
//Intent's data and any URIs specified in its ClipData.
if(clipData == null){
clipData = ClipData("", emptyArray(), ClipData.Item(it.toUri()))
} else {
clipData!!.addItem(ClipData.Item(it.toUri()))
}
}
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
if(inActivity && !addToStory){
requireActivity().setResult(Activity.RESULT_OK, intent)
@ -461,9 +473,6 @@ class CameraFragment : BaseFragment() {
if(addToStory){
intent.putExtra(CAMERA_ACTIVITY_STORY, addToStory)
}
if(!inActivity && tutorial != -1){
intent.putExtra(START_TUTORIAL, true)
}
startActivity(intent)
}
}

@ -3,99 +3,40 @@ package org.pixeldroid.app.posts
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.viewpager2.widget.ViewPager2
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.ActivityAlbumBinding
import org.pixeldroid.app.utils.api.objects.Attachment
class AlbumActivity : AppCompatActivity() {
private val model: AlbumViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityAlbumBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.albumPager.adapter = AlbumViewPagerAdapter(
model.uiState.value.mediaAttachments,
setContentView(binding.root)
val mediaAttachments = intent.getSerializableExtra("images") as ArrayList<Attachment>
val index = intent.getIntExtra("index", 0)
binding.albumPager.adapter = AlbumViewPagerAdapter(mediaAttachments,
sensitive = false,
opened = true,
//In the activity, we assume we want to show everything
alwaysShowNsfw = true,
clickCallback = ::clickCallback
alwaysShowNsfw = true
)
binding.albumPager.currentItem = index
binding.albumPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { model.positionSelected(position) }
})
if (model.uiState.value.mediaAttachments.size == 1) {
if(mediaAttachments.size == 1){
binding.albumPager.isUserInputEnabled = false
} else if ((model.uiState.value.mediaAttachments.size) > 1) {
}
else if((mediaAttachments.size) > 1) {
binding.postIndicator.setViewPager(binding.albumPager)
binding.postIndicator.visibility = View.VISIBLE
} else {
binding.postIndicator.visibility = View.GONE
}
// Not really necessary because the ViewPager saves its state in onSaveInstanceState, but
// it's good to stay consistent in case something gets out of sync
binding.albumPager.setCurrentItem(model.uiState.value.index, false)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
supportActionBar?.setBackgroundDrawable(null)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.uiState.collect { uiState ->
binding.albumPager.currentItem = uiState.index
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.isActionBarHidden.collect { isActionBarHidden ->
val windowInsetsController =
WindowCompat.getInsetsController(this@AlbumActivity.window, binding.albumPager)
if (isActionBarHidden) {
// Configure the behavior of the hidden system bars
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Hide both the status bar and the navigation bar
supportActionBar?.hide()
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
binding.postIndicator.visibility = View.GONE
} else {
// Configure the behavior of the hidden system bars
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Show both the status bar and the navigation bar
supportActionBar?.show()
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
if ((model.uiState.value.mediaAttachments.size) > 1) {
binding.postIndicator.visibility = View.VISIBLE
}
}
}
}
}
}
/**
* Callback passed to the AlbumViewPagerAdapter to signal a single click on the image
*/
private fun clickCallback(){
model.barHide()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {

@ -1,46 +0,0 @@
package org.pixeldroid.app.posts
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.pixeldroid.app.utils.api.objects.Attachment
import javax.inject.Inject
data class AlbumUiState(
val mediaAttachments: ArrayList<Attachment> = arrayListOf(),
val index: Int = 0,
)
@HiltViewModel
class AlbumViewModel @Inject constructor(state: SavedStateHandle) : ViewModel() {
companion object {
const val ALBUM_IMAGES = "AlbumViewImages"
const val ALBUM_INDEX = "AlbumViewIndex"
}
private val _uiState: MutableStateFlow<AlbumUiState>
private val _isActionBarHidden: MutableStateFlow<Boolean>
init {
_uiState = MutableStateFlow(AlbumUiState(
mediaAttachments = state[ALBUM_IMAGES] ?: ArrayList(),
index = state[ALBUM_INDEX] ?: 0
))
_isActionBarHidden = MutableStateFlow(false)
}
val uiState: StateFlow<AlbumUiState> = _uiState.asStateFlow()
val isActionBarHidden: StateFlow<Boolean> = _isActionBarHidden
fun barHide() {
_isActionBarHidden.update { !it }
}
fun positionSelected(position: Int) {
_uiState.update { it.copy(index = position) }
}
}

@ -88,8 +88,8 @@ class NestedScrollableHost(context: Context, attrs: AttributeSet? = null) :
}
val intent = Intent(context, AlbumActivity::class.java)
intent.putExtra(AlbumViewModel.ALBUM_IMAGES, images)
intent.putExtra(AlbumViewModel.ALBUM_INDEX, (child as ViewPager2).currentItem)
intent.putExtra("images", images)
intent.putExtra("index", (child as ViewPager2).currentItem)
context.startActivity(intent)

@ -6,7 +6,6 @@ import android.view.View
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R
@ -23,18 +22,15 @@ import org.pixeldroid.app.utils.api.objects.Status.Companion.VIEW_COMMENTS_TAG
import org.pixeldroid.app.utils.displayDimensionsInPx
class PostActivity : BaseActivity() {
lateinit var binding: ActivityPostBinding
private lateinit var binding: ActivityPostBinding
private lateinit var commentFragment: CommentFragment
private var commentFragment = CommentFragment()
private lateinit var status: Status
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPostBinding.inflate(layoutInflater)
commentFragment = CommentFragment()
setContentView(binding.root)
setSupportActionBar(binding.topBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@ -48,15 +44,14 @@ class PostActivity : BaseActivity() {
supportActionBar?.title = getString(R.string.post_title).format(status.account?.getDisplayName())
val holder = StatusViewHolder(binding.postFragmentSingle)
val (width, height) = displayDimensionsInPx()
holder.bind(
status, apiHolder, db, lifecycleScope, Pair((width*.7).toInt(), height),
status, apiHolder, db, lifecycleScope, displayDimensionsInPx(),
requestPermissionDownloadPic, isActivity = true
)
activateCommenter()
initCommentsFragment(domain = user?.instance_uri.orEmpty(), savedInstanceState)
initCommentsFragment(domain = user?.instance_uri.orEmpty())
if(viewComments || postComment){
//Scroll already down as much as possible (since comments are not loaded yet)
@ -101,26 +96,15 @@ class PostActivity : BaseActivity() {
}
}
private fun initCommentsFragment(domain: String, savedInstanceState: Bundle?) {
private fun initCommentsFragment(domain: String) {
val arguments = Bundle()
arguments.putSerializable(COMMENT_STATUS_ID, status.id)
arguments.putSerializable(COMMENT_DOMAIN, domain)
commentFragment.arguments = arguments
//TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
//Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.commentFragment, commentFragment)
}
}
binding.swipeRefreshLayout.setOnRefreshListener {
commentFragment.adapter.refresh()
commentFragment.adapter.notifyDataSetChanged()
}
supportFragmentManager.beginTransaction()
.add(R.id.commentFragment, commentFragment).commit()
}
private suspend fun postComment(

@ -2,22 +2,15 @@ package org.pixeldroid.app.posts
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityReportBinding
import org.pixeldroid.app.posts.ReportActivityViewModel.UploadState
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Status
class ReportActivity : BaseActivity() {
private lateinit var binding: ActivityReportBinding
private val model: ReportActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -31,47 +24,42 @@ class ReportActivity : BaseActivity() {
binding.reportTargetTextview.text = getString(R.string.report_target).format(status?.account?.acct)
binding.textInputLayout.editText?.text = model.editable
binding.textInputLayout.editText?.doAfterTextChanged { model.textChanged(it) }
binding.reportButton.setOnClickListener{
binding.reportButton.visibility = View.INVISIBLE
binding.reportProgressBar.visibility = View.VISIBLE
binding.reportButton.setOnClickListener {
model.sendReport(status, binding.textInputLayout.editText?.text.toString())
}
binding.textInputLayout.editText?.isEnabled = false
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
model.reportSent.collect {
reportStatus(it)
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
lifecycleScope.launchWhenCreated {
try {
api.report(
status?.account?.id!!,
listOf(status),
binding.textInputLayout.editText?.text.toString()
)
reportStatus(true)
} catch (exception: Exception) {
reportStatus(false)
}
}
}
}
private fun reportStatus(success: UploadState){
when (success) {
UploadState.initial -> {
binding.reportProgressBar.visibility = View.GONE
binding.reportButton.visibility = View.VISIBLE
binding.reportSuccess.visibility = View.INVISIBLE
}
UploadState.success -> {
binding.reportProgressBar.visibility = View.GONE
binding.reportButton.visibility = View.INVISIBLE
binding.reportSuccess.visibility = View.VISIBLE
}
UploadState.failed -> {
binding.textInputLayout.error = getString(R.string.report_error)
binding.reportButton.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = true
binding.reportProgressBar.visibility = View.GONE
binding.reportSuccess.visibility = View.GONE
}
UploadState.inProgress -> {
binding.reportButton.visibility = View.INVISIBLE
binding.reportProgressBar.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = false
}
private fun reportStatus(success: Boolean){
if(success){
binding.reportProgressBar.visibility = View.GONE
binding.reportButton.visibility = View.INVISIBLE
binding.reportSuccess.visibility = View.VISIBLE
} else {
binding.textInputLayout.error = getString(R.string.report_error)
binding.reportButton.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = true
binding.reportProgressBar.visibility = View.GONE
binding.reportSuccess.visibility = View.GONE
}
}
}

@ -1,47 +0,0 @@
package org.pixeldroid.app.posts
import android.text.Editable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import javax.inject.Inject
@HiltViewModel
class ReportActivityViewModel @Inject constructor(val apiHolder: PixelfedAPIHolder): ViewModel() {
var editable: Editable? = null
private set
private val _reportSent: MutableStateFlow<UploadState> = MutableStateFlow(UploadState.initial)
val reportSent = _reportSent.asStateFlow()
enum class UploadState {
initial, success, failed, inProgress
}
fun textChanged(it: Editable?) {
editable = it
}
fun sendReport(status: Status?, text: String) {
_reportSent.value = UploadState.inProgress
viewModelScope.launch {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
try {
api.report(
status?.account?.id!!,
listOf(status),
text
)
_reportSent.value = UploadState.success
} catch (exception: Exception) {
_reportSent.value = UploadState.failed
}
}
}
}

@ -1,8 +1,11 @@
package org.pixeldroid.app.posts
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_DENIED
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.graphics.Typeface
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
@ -17,7 +20,11 @@ import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
@ -70,7 +77,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
fun bind(
status: Status?, pixelfedAPI: PixelfedAPIHolder, db: AppDatabase,
lifecycleScope: LifecycleCoroutineScope, displayDimensionsInPx: Pair<Int, Int>,
requestPermissionDownloadPic: ActivityResultLauncher<String>, isActivity: Boolean = false,
requestPermissionDownloadPic: ActivityResultLauncher<String>, isActivity: Boolean = false
) {
this.itemView.visibility = View.VISIBLE
@ -152,7 +159,8 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
if(!status?.media_attachments.isNullOrEmpty()) {
setupPostPics(binding, request)
} else {
binding.postConstraint.visibility = View.GONE
binding.postPager.visibility = View.GONE
binding.postIndicator.visibility = View.GONE
}
}
@ -363,7 +371,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
apiHolder: PixelfedAPIHolder,
db: AppDatabase,
lifecycleScope: LifecycleCoroutineScope,
requestPermissionDownloadPic: ActivityResultLauncher<String>,
requestPermissionDownloadPic: ActivityResultLauncher<String>
){
var bookmarked: Boolean? = null
binding.statusMore.setOnClickListener {
@ -441,7 +449,178 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
true
}
R.id.post_more_menu_redraft -> launchRedraftDialog(lifecycleScope, apiHolder, db)
R.id.post_more_menu_redraft -> {
MaterialAlertDialogBuilder(binding.root.context).apply {
setMessage(R.string.redraft_dialog_launch)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch {
try {
// Create new post creation activity
val intent =
Intent(context, PostCreationActivity::class.java)
// Get descriptions and images from original post
val postDescription = status?.content ?: ""
val postAttachments =
status?.media_attachments!! // Catch possible exception from !! (?)
val postNSFW = status?.sensitive
val imageUriStrings = postAttachments.map { postAttachment ->
postAttachment.url ?: ""
}
val imageNames = imageUriStrings.map { imageUriString ->
Uri.parse(imageUriString).lastPathSegment.toString()
}
val downloadedFiles = imageNames.map { imageName ->
File(context.cacheDir, imageName)
}
val imageUris = downloadedFiles.map { downloadedFile ->
Uri.fromFile(downloadedFile)
}
val imageDescriptions = postAttachments.map { postAttachment ->
fromHtml(postAttachment.description ?: "").toString()
}
val downloadRequests: List<Request> = imageUriStrings.map { imageUriString ->
Request.Builder().url(imageUriString).build()
}
val counter = AtomicInteger(0)
// Define callback function for after downloading the images
fun continuation() {
// Wait for all outstanding downloads to finish
if (counter.incrementAndGet() == imageUris.size) {
if (allFilesExist(imageNames)) {
// Delete original post
lifecycleScope.launch {
deletePost(apiHolder.api ?: apiHolder.setToCurrentUser(), db)
}
val counterInt = counter.get()
Toast.makeText(
binding.root.context,
binding.root.context.resources.getQuantityString(
R.plurals.items_load_success,
counterInt,
counterInt
),
Toast.LENGTH_SHORT
).show()
// Pass downloaded images to new post creation activity
intent.apply {
imageUris.zip(imageDescriptions).map { (imageUri, imageDescription) ->
ClipData.Item(imageDescription, null, imageUri)
}.forEach { imageItem ->
if (clipData == null) {
clipData = ClipData(
"",
emptyArray(),
imageItem
)
} else {
clipData!!.addItem(imageItem)
}
}
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Pass post description of existing post to new post creation activity
intent.putExtra(
PostCreationActivity.PICTURE_DESCRIPTION,
fromHtml(postDescription).toString()
)
if (imageNames.isNotEmpty()) {
intent.putExtra(
PostCreationActivity.TEMP_FILES,
imageNames.toTypedArray()
)
}
intent.putExtra(
PostCreationActivity.POST_REDRAFT,
true
)
intent.putExtra(
PostCreationActivity.POST_NSFW,
postNSFW
)
// Launch post creation activity
binding.root.context.startActivity(intent)
}
}
}
if (!allFilesExist(imageNames)) {
// Track download progress
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.image_download_downloading),
Toast.LENGTH_SHORT
).show()
}
// Iterate through all pictures of the original post
downloadRequests.zip(downloadedFiles).forEach { (downloadRequest, downloadedFile) ->
// Check whether image is in cache directory already (maybe rather do so using Glide in the future?)
if (!downloadedFile.exists()) {
OkHttpClient().newCall(downloadRequest)
.enqueue(object : Callback {
override fun onFailure(
call: Call,
e: IOException
) {
Looper.prepare()
downloadedFile.delete()
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.redraft_post_failed_io_except),
Toast.LENGTH_SHORT
).show()
}
@Throws(IOException::class)
override fun onResponse(
call: Call,
response: Response
) {
val sink: BufferedSink =
downloadedFile.sink().buffer()
sink.writeAll(response.body!!.source())
sink.close()
Looper.prepare()
continuation()
}
})
} else {
continuation()
}
}
} catch (exception: HttpException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(
R.string.redraft_post_failed_error,
exception.code()
),
Toast.LENGTH_SHORT
).show()
} catch (exception: IOException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.redraft_post_failed_io_except),
Toast.LENGTH_SHORT
).show()
}
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
show()
}
true
}
else -> false
}
}
@ -466,165 +645,6 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
}
}
private fun launchRedraftDialog(
lifecycleScope: LifecycleCoroutineScope,
apiHolder: PixelfedAPIHolder,
db: AppDatabase
): Boolean {
MaterialAlertDialogBuilder(binding.root.context).apply {
setMessage(R.string.redraft_dialog_launch)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch {
try {
// Get descriptions and images from original post
val postDescription = status?.content ?: ""
val postAttachments =
status?.media_attachments!! // TODO Catch possible exception from !! (?)
val postNSFW = status?.sensitive
val imageUriStrings = postAttachments.map { postAttachment ->
postAttachment.url ?: ""
}
val imageNames = imageUriStrings.map { imageUriString ->
Uri.parse(imageUriString).lastPathSegment.toString()
}
val downloadedFiles = imageNames.map { imageName ->
File(context.cacheDir, imageName)
}
val imageDescriptions = postAttachments.map { postAttachment ->
fromHtml(
postAttachment.description ?: ""
).toString()
}
val downloadRequests: List<Request> =
imageUriStrings.map { imageUriString ->
Request.Builder().url(imageUriString).build()
}
val imageUris = downloadedFiles.map { downloadedFile ->
Uri.fromFile(downloadedFile)
}
val counter = AtomicInteger(0)
// Define callback function for after downloading the images
fun continuation() {
// Wait for all outstanding downloads to finish
if (counter.incrementAndGet() == imageUris.size) {
if (allFilesExist(imageNames)) {
// Delete original post
lifecycleScope.launch {
deletePost(
apiHolder.api ?: apiHolder.setToCurrentUser(), db
)
}
val counterInt = counter.get()
Toast.makeText(
binding.root.context,
binding.root.context.resources.getQuantityString(
R.plurals.items_load_success, counterInt, counterInt
),
Toast.LENGTH_SHORT
).show()
// Create new post creation activity
val intent = PostCreationActivity.intentForUris(context, imageUris).apply {
putExtra(
PostCreationActivity.PICTURE_DESCRIPTIONS,
ArrayList(imageDescriptions)
)
// Pass post description of existing post to new post creation activity
putExtra(
PostCreationActivity.POST_DESCRIPTION,
fromHtml(postDescription).toString()
)
if (imageNames.isNotEmpty()) {
putExtra(
PostCreationActivity.TEMP_FILES,
imageNames.toTypedArray()
)
}
putExtra(PostCreationActivity.POST_REDRAFT, true)
putExtra(PostCreationActivity.POST_NSFW, postNSFW)
}
// Launch post creation activity
binding.root.context.startActivity(intent)
}
}
}
if (!allFilesExist(imageNames)) {
// Track download progress
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.image_download_downloading),
Toast.LENGTH_SHORT
).show()
}
// Iterate through all pictures of the original post
downloadRequests.zip(downloadedFiles)
.forEach { (downloadRequest, downloadedFile) ->
// Check whether image is in cache directory already (maybe rather do so using Glide in the future?)
if (!downloadedFile.exists()) {
OkHttpClient().newCall(downloadRequest)
.enqueue(object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
Looper.prepare()
downloadedFile.delete()
Toast.makeText(
binding.root.context,
binding.root.context.getString(
R.string.redraft_post_failed_io_except
),
Toast.LENGTH_SHORT
).show()
}
@Throws(IOException::class)
override fun onResponse(
call: Call,
response: Response,
) {
val sink: BufferedSink =
downloadedFile.sink().buffer()
sink.writeAll(response.body!!.source())
sink.close()
Looper.prepare()
continuation()
}
})
} else {
continuation()
}
}
} catch (exception: HttpException) {
Toast.makeText(
binding.root.context, binding.root.context.getString(
R.string.redraft_post_failed_error, exception.code()
), Toast.LENGTH_SHORT
).show()
} catch (exception: IOException) {
Toast.makeText(
binding.root.context,
binding.root.context.getString(R.string.redraft_post_failed_io_except),
Toast.LENGTH_SHORT
).show()
}
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
show()
}
return true
}
private fun activateLiker(
apiHolder: PixelfedAPIHolder,
isLiked: Boolean,
@ -800,15 +820,17 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
class AlbumViewPagerAdapter(
private val media_attachments: List<Attachment>, private var sensitive: Boolean?,
private val opened: Boolean, private val alwaysShowNsfw: Boolean,
private val clickCallback: (() -> Unit)? = null
) : RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
) :
RecyclerView.Adapter<AlbumViewPagerAdapter.ViewHolder>() {
private var isActionBarHidden: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return if(!opened) ViewHolderClosed(AlbumImageViewBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)) else ViewHolderOpen(OpenedAlbumBinding.inflate(
LayoutInflater.from(parent.context), parent, false
), clickCallback!!)
))
}
override fun getItemCount() = media_attachments.size
@ -839,6 +861,24 @@ class AlbumViewPagerAdapter(
setDoubleTapZoomDpi(240)
resetScaleAndCenter()
}
holder.image.setOnClickListener {
val windowInsetsController = WindowCompat.getInsetsController((it.context as Activity).window, it)
// Configure the behavior of the hidden system bars
if (isActionBarHidden) {
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Hide both the status bar and the navigation bar
(it.context as AppCompatActivity).supportActionBar?.show()
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
isActionBarHidden = false
} else {
// Configure the behavior of the hidden system bars
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Hide both the status bar and the navigation bar
(it.context as AppCompatActivity).supportActionBar?.hide()
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
isActionBarHidden = true
}
}
}
else Glide.with(holder.binding.root)
.asDrawable().fitCenter()
@ -884,13 +924,9 @@ class AlbumViewPagerAdapter(
abstract val videoPlayButton: ImageView
}
class ViewHolderOpen(override val binding: OpenedAlbumBinding, clickCallback: () -> Unit) : ViewHolder(binding) {
class ViewHolderOpen(override val binding: OpenedAlbumBinding) : ViewHolder(binding) {
override val image: SubsamplingScaleImageView = binding.imageImageView
override val videoPlayButton: ImageView = binding.videoPlayButton
init {
image.setOnClickListener { clickCallback() }
}
}
class ViewHolderClosed(override val binding: AlbumImageViewBinding) : ViewHolder(binding) {
override val image: ImageView = binding.imageImageView

@ -50,7 +50,7 @@ private fun showError(
* Makes the UI respond to various [LoadState]s, including errors when an error message is shown.
*/
internal fun <T: Any> initAdapter(
progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout?,
progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout,
recyclerView: RecyclerView, motionLayout: MotionLayout, errorLayout: ErrorLayoutBinding,
adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>,
header: StoriesAdapter? = null
@ -71,15 +71,14 @@ internal fun <T: Any> initAdapter(
).toTypedArray()
)
swipeRefreshLayout?.setOnRefreshListener {
swipeRefreshLayout.setOnRefreshListener {
adapter.refresh()
adapter.notifyDataSetChanged()
header?.refreshStories()
}
adapter.addLoadStateListener { loadState ->
if(!progressBar.isVisible && swipeRefreshLayout?.isRefreshing == true) {
if(!progressBar.isVisible && swipeRefreshLayout.isRefreshing) {
// Stop loading spinner when loading is done
swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading
}

@ -1,27 +1,22 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState.NotLoading
import androidx.paging.PagingDataAdapter
import androidx.paging.RemoteMediator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import org.pixeldroid.app.databinding.FragmentFeedBinding
import org.pixeldroid.app.directmessages.BounceEdgeEffectFactory
import org.pixeldroid.app.posts.feeds.initAdapter
import org.pixeldroid.app.stories.StoriesAdapter
import org.pixeldroid.app.utils.BaseFragment
@ -60,69 +55,38 @@ open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
//TODO rename function to something that makes sense
internal fun initSearch() {
// Scroll to top when the list is refreshed from network.
// lifecycleScope.launchWhenStarted {
// adapter.loadStateFlow
// // Only emit when REFRESH LoadState for RemoteMediator changes.
// .distinctUntilChangedBy {
// it.refresh
// }
// // Only react to cases where Remote REFRESH completes i.e., NotLoading.
// .filter { it.refresh is NotLoading}
// .collect { binding.list.scrollToPosition(0) }
// }
lifecycleScope.launchWhenStarted {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy {
it.refresh
}
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is NotLoading}
.collect { binding.list.scrollToPosition(0) }
}
}
fun createView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?, reverseLayout: Boolean = false): ConstraintLayout {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
val callback: () -> Unit = {
binding.bottomLoadingBar.visibility = View.VISIBLE
GlobalScope.launch(Dispatchers.Main) {
delay(1000) // Wait 1 second
binding.bottomLoadingBar.visibility = View.GONE
}
adapter.refresh()
adapter.notifyDataSetChanged()
}
val swipeRefreshLayout = if(reverseLayout) {
binding.swipeRefreshLayout.isEnabled = false
binding.list.apply {
layoutManager = LinearLayoutManager(context).apply {
stackFromEnd = false
this.reverseLayout = true
}
edgeEffectFactory = BounceEdgeEffectFactory(callback, context)
}
null
} else binding.swipeRefreshLayout
initAdapter(binding.progressBar, swipeRefreshLayout,
initAdapter(binding.progressBar, binding.swipeRefreshLayout,
binding.list, binding.motionLayout, binding.errorLayout, adapter,
headerAdapter
)
return binding.root
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return createView(inflater, container, savedInstanceState, false)
}
fun onTabReClicked() {
binding.list.limitedLengthSmoothScrollToPosition(0)
}
private fun onPullUp() {
// Handle the pull-up action
Log.e("bottom", "reached")
}
}

@ -16,24 +16,21 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.RemoteMediator
import kotlinx.coroutines.flow.Flow
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
import androidx.paging.*
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Repository class that works with local and remote data sources.
*/
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi constructor(
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi
@Inject constructor(
private val db: AppDatabase,
private val dao: FeedContentDao<T>,
private val mediator: RemoteMediator<Int, T>,
private val conversationsId: String = "",
private val mediator: RemoteMediator<Int, T>
) {
/**
@ -45,7 +42,7 @@ class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi const
val user = db.userDao().getActiveUser()!!
val pagingSourceFactory = {
dao.feedContent(user.user_id, user.instance_uri, conversationsId)
dao.feedContent(user.user_id, user.instance_uri)
}
return Pager(

@ -0,0 +1,262 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds.directMessages
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.FragmentConversationsBinding
import org.pixeldroid.app.databinding.FragmentNotificationsBinding
import org.pixeldroid.app.posts.PostActivity
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
import org.pixeldroid.app.posts.parseHTMLText
import org.pixeldroid.app.posts.setTextViewFromISO8601
import org.pixeldroid.app.profile.ProfileActivity
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
/**
* Fragment for the notifications tab.
*/
class DirectMessagesFragment : CachedFeedFragment<Conversation>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = DirectMessagesAdapter(apiHolder)
}
@OptIn(ExperimentalPagingApi::class)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(
requireActivity(),
ViewModelFactory(db, db.directMessagesDao(), DirectMessagesRemoteMediator(apiHolder, db))
)["conversations", FeedViewModel::class.java] as FeedViewModel<Conversation>
launch()
initSearch()
return view
}
override fun onResume() {
super.onResume()
with(NotificationManagerCompat.from(requireContext())) {
// Cancel account notification group
db.userDao().getActiveUser()?.let {
cancel( makeChannelGroupId(it).hashCode())
}
}
}
/**
* View Holder for a [Conversation] RecyclerView list item.
*/
class ConversationViewHolder(binding: FragmentConversationsBinding) : RecyclerView.ViewHolder(binding.root) {
private val messageTime: TextView = binding.messageTime
private val avatar: ImageView = binding.notificationAvatar
private val photoThumbnail: ImageView = binding.notificationPhotoThumbnail
private var conversation: Conversation? = null
init {
itemView.setOnClickListener {
conversation?.openActivity()
}
avatar.setOnClickListener {
val intent = conversation?.openAccountFromNotification()
itemView.context.startActivity(intent)
}
}
private fun Conversation.openActivity() {
val intent: Intent = openConversation()
itemView.context.startActivity(intent)
}
private fun Notification.openConversation(): Intent =
Intent(itemView.context, PostActivity::class.java).apply {
putExtra(Status.POST_TAG, status)
}
private fun setNotificationType(
type: Notification.NotificationType,
username: String,
textView: TextView
) {
val context = textView.context
val (format: String, drawable: Drawable?) = when (type) {
Notification.NotificationType.follow ->
getStringAndDrawable(
context,
R.string.followed_notification,
R.drawable.ic_follow
)
Notification.NotificationType.mention ->
getStringAndDrawable(
context,
R.string.mention_notification,
R.drawable.mention_at_24dp
)
Notification.NotificationType.comment ->
getStringAndDrawable(
context,
R.string.comment_notification,
R.drawable.ic_comment_empty
)
Notification.NotificationType.reblog ->
getStringAndDrawable(
context,
R.string.shared_notification,
R.drawable.ic_reblog_blue
)
Notification.NotificationType.favourite ->
getStringAndDrawable(
context,
R.string.liked_notification,
R.drawable.ic_like_full
)
Notification.NotificationType.poll ->
getStringAndDrawable(context, R.string.poll_notification, R.drawable.poll)
Notification.NotificationType.follow_request -> getStringAndDrawable(
context,
R.string.follow_request,
R.drawable.ic_follow
)
Notification.NotificationType.status -> getStringAndDrawable(
context,
R.string.status_notification,
R.drawable.ic_comment_empty
)
}
textView.text = format.format(username)
textView.setCompoundDrawablesWithIntrinsicBounds(
drawable, null, null, null
)
}
private fun getStringAndDrawable(
context: Context,
stringToFormat: Int,
drawable: Int
): Pair<String, Drawable?> =
Pair(context.getString(stringToFormat), ContextCompat.getDrawable(context, drawable))
fun bind(
conversation: Conversation?,
api: PixelfedAPIHolder,
lifecycleScope: LifecycleCoroutineScope,
) {
this.conversation = conversation
Glide.with(itemView).load(conversation?.accounts?.first()?.anyAvatar()).circleCrop()
.into(avatar)
val previewUrl = conversation?.accounts?.first()?.anyAvatar()
if (!previewUrl.isNullOrBlank()) {
Glide.with(itemView).load(previewUrl)
.placeholder(R.drawable.ic_picture_fallback).into(photoThumbnail)
} else {
photoThumbnail.visibility = View.GONE
}
conversation?.last_status?.created_at?.let {
setTextViewFromISO8601(
it,
notificationTime,
false
)
}
// Convert HTML to clickable text
postDescription.text =
parseHTMLText(
notification?.status?.content ?: "",
notification?.status?.mentions,
api,
itemView.context,
lifecycleScope
)
}
companion object {
fun create(parent: ViewGroup): ConversationViewHolder {
val itemBinding = FragmentNotificationsBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ConversationViewHolder(itemBinding)
}
}
}
inner class DirectMessagesAdapter(
private val apiHolder: PixelfedAPIHolder,
) : PagingDataAdapter<Conversation, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<Conversation>() {
override fun areItemsTheSame(
oldItem: Conversation,
newItem: Conversation
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: Conversation,
newItem: Conversation
): Boolean =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ConversationViewHolder.create(parent)
}
override fun getItemViewType(position: Int): Int {
return R.layout.fragment_notifications
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel?.let {
(holder as ConversationViewHolder).bind(
it,
apiHolder,
lifecycleScope
)
}
}
}
}

@ -14,16 +14,13 @@
* limitations under the License.
*/
package org.pixeldroid.app.directmessages
package org.pixeldroid.app.posts.feeds.cachedFeeds.directMessages
import androidx.paging.*
import androidx.paging.PagingSource.LoadResult
import androidx.room.withTransaction
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.api.objects.Notification
import retrofit2.HttpException
import java.lang.Exception
import java.lang.NullPointerException
import javax.inject.Inject
@ -42,29 +39,26 @@ class DirectMessagesRemoteMediator @Inject constructor(
) : RemoteMediator<Int, Conversation>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Conversation>): MediatorResult {
val maxId = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> {
// No prepend for the moment, might be nice to add later
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> state.lastItemOrNull()?.id
?: return MediatorResult.Success(endOfPaginationReached = true)
}
try {
val user = db.userDao().getActiveUser()
?: return MediatorResult.Error(NullPointerException("No active user exists"))
?: return MediatorResult.Error(NullPointerException("No active user exists"))
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val nextPage = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> {
// No prepend for the moment, might be nice to add later
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> state.lastItemOrNull()?.id?.toIntOrNull()
?.let { state.closestPageToPosition(it) }?.nextKey
?: return MediatorResult.Success(endOfPaginationReached = true)
}
val apiResponse =
// Pixelfed uses Laravel's paging mechanism for pagination.
//TODO, implement also for Mastodon (see FollowersPagingSource)
api.directMessagesList(
limit = state.config.pageSize.toString(),
page = nextPage
)
val apiResponse = api.viewAllConversations(
max_id = maxId,
limit = state.config.pageSize.toString()
)
apiResponse.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}

@ -1,14 +1,12 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.paging.*
import androidx.room.withTransaction
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import java.lang.NullPointerException
import javax.inject.Inject
/**
@ -19,7 +17,7 @@ import org.pixeldroid.app.utils.di.PixelfedAPIHolder
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class HomeFeedRemoteMediator(
class HomeFeedRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase,
) : RemoteMediator<Int, HomeStatusDatabaseEntity>() {

@ -40,6 +40,8 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
home = requireArguments().getBoolean("home")
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
@Suppress("UNCHECKED_CAST")
if (home){
mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
@ -59,7 +61,6 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
val view = super.onCreateView(inflater, container, savedInstanceState)

@ -16,15 +16,13 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.paging.*
import androidx.room.withTransaction
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import java.lang.NullPointerException
import javax.inject.Inject
/**
* RemoteMediator for the public feed.
@ -34,7 +32,7 @@ import org.pixeldroid.app.utils.di.PixelfedAPIHolder
* a local db cache.
*/
@OptIn(ExperimentalPagingApi::class)
class PublicFeedRemoteMediator(
class PublicFeedRemoteMediator @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase
) : RemoteMediator<Int, PublicFeedStatusDatabaseEntity>() {

@ -8,16 +8,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.FragmentFeedBinding
import org.pixeldroid.app.posts.feeds.initAdapter
import org.pixeldroid.app.posts.feeds.launch
import org.pixeldroid.app.utils.BaseFragment
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.limitedLengthSmoothScrollToPosition
/**
@ -28,7 +30,8 @@ open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
internal lateinit var viewModel: FeedViewModel<T>
internal lateinit var adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>
var binding: FragmentFeedBinding? = null
lateinit var binding: FragmentFeedBinding
private var job: Job? = null
@ -38,42 +41,32 @@ open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
}
internal fun initSearch() {
// // Scroll to top when the list is refreshed from network.
// lifecycleScope.launch {
// adapter.loadStateFlow
// // Only emit when REFRESH LoadState for RemoteMediator changes.
// .distinctUntilChangedBy { it.refresh }
// // Only react to cases where Remote REFRESH completes i.e., NotLoading.
// .filter { it.refresh is LoadState.NotLoading }
// .collect { binding?.list?.scrollToPosition(0) }
// }
}
fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?, swipeRefreshLayout: SwipeRefreshLayout?
): View {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
binding!!.let {
initAdapter(
it.progressBar, swipeRefreshLayout ?: it.swipeRefreshLayout, it.list,
it.motionLayout, it.errorLayout, adapter
)
// Scroll to top when the list is refreshed from network.
lifecycleScope.launch {
adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading }
.collect { binding.list.scrollToPosition(0) }
}
return binding!!.root
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return onCreateView(inflater, container, savedInstanceState, null)
}
fun onTabReClicked() {
binding?.list?.limitedLengthSmoothScrollToPosition(0)
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
initAdapter(
binding.progressBar, binding.swipeRefreshLayout, binding.list,
binding.motionLayout, binding.errorLayout, adapter
)
return binding.root
}
}

@ -4,15 +4,11 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.StatusViewHolder
import org.pixeldroid.app.posts.feeds.UIMODEL_STATUS_COMPARATOR
@ -23,7 +19,6 @@ import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
import org.pixeldroid.app.utils.displayDimensionsInPx
/**
* Fragment to show a list of [Status]es, as a result of a search or a hashtag.
*/
@ -38,7 +33,7 @@ class UncachedPostsFragment : UncachedFeedFragment<Status>() {
hashtagOrQuery = arguments?.getString(HASHTAG_TAG)
if (hashtagOrQuery == null) {
if(hashtagOrQuery == null){
search = true
hashtagOrQuery = arguments?.getString("searchFeed")!!
}

@ -5,15 +5,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.NestedScrollingChild
import androidx.core.view.NestedScrollingChildHelper
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.CommentBinding
import org.pixeldroid.app.posts.PostActivity
@ -28,7 +25,7 @@ import org.pixeldroid.app.utils.setProfileImageFromURL
/**
* Fragment to show a list of [Status]s, in form of comments
*/
class CommentFragment: UncachedFeedFragment<Status>() {
class CommentFragment : UncachedFeedFragment<Status>() {
private lateinit var id: String
private lateinit var domain: String
@ -45,13 +42,11 @@ class CommentFragment: UncachedFeedFragment<Status>() {
@OptIn(ExperimentalPagingApi::class)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
savedInstanceState: Bundle?
): View? {
val view = super.onCreateView(
inflater, container, savedInstanceState,
(activity as? PostActivity)?.binding?.swipeRefreshLayout
)
val view = super.onCreateView(inflater, container, savedInstanceState)
// Get the view model
@Suppress("UNCHECKED_CAST")
@ -67,7 +62,6 @@ class CommentFragment: UncachedFeedFragment<Status>() {
launch()
initSearch()
binding?.swipeRefreshLayout?.isEnabled = false
return view
}
companion object {

@ -1,8 +1,6 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import android.os.Bundle
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
@ -11,8 +9,10 @@ import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
class HashTagActivity : BaseActivity() {
private var tagFragment = UncachedPostsFragment()
private lateinit var binding: ActivityFollowersBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFollowersBinding.inflate(layoutInflater)
@ -32,10 +32,10 @@ class HashTagActivity : BaseActivity() {
val arguments = Bundle()
arguments.putSerializable(HASHTAG_TAG, tag)
tagFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.followsFragment, tagFragment).commit()
supportFragmentManager.commit {
setReorderingAllowed(true)
replace<UncachedPostsFragment>(R.id.conversationFragment, args = arguments)
}
}
}

@ -87,7 +87,7 @@ class CollectionActivity : BaseActivity() {
return true
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
// Relaunch same activity, to avoid duplicates in history
super.onNewIntent(intent)
finish()

@ -25,7 +25,7 @@ import org.pixeldroid.app.utils.openUrl
class EditProfileActivity : BaseActivity() {
private val model: EditProfileViewModel by viewModels()
private lateinit var model: EditProfileViewModel
private lateinit var binding: ActivityEditProfileBinding
override fun onCreate(savedInstanceState: Bundle?) {
@ -35,6 +35,9 @@ class EditProfileActivity : BaseActivity() {
setSupportActionBar(binding.topBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val _model: EditProfileViewModel by viewModels { EditProfileViewModelFactory(application) }
model = _model
onBackPressedDispatcher.addCallback(this) {
// Handle the back button event
if(model.madeChanges()){
@ -42,38 +45,36 @@ class EditProfileActivity : BaseActivity() {
setMessage(getString(R.string.profile_save_changes))
setNegativeButton(android.R.string.cancel) { _, _ -> }
setPositiveButton(android.R.string.ok) { _, _ ->
this@addCallback.remove()
onBackPressedDispatcher.onBackPressed()
this@addCallback.isEnabled = false
super.onBackPressedDispatcher.onBackPressed()
}
}.show()
} else {
this@addCallback.remove()
if (model.submittedChanges) setResult(RESULT_OK)
onBackPressedDispatcher.onBackPressed()
this.isEnabled = false
super.onBackPressedDispatcher.onBackPressed()
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.uiState.collect { uiState ->
if(binding.bioEditText.text.toString() != uiState.bio) binding.bioEditText.setText(uiState.bio)
if(binding.nameEditText.text.toString() != uiState.name) binding.nameEditText.setText(uiState.name)
binding.progressCard.visibility = if(uiState.loadingProfile || uiState.sendingProfile || uiState.uploadingPicture || uiState.profileSent || uiState.error) View.VISIBLE else View.INVISIBLE
if(uiState.profileLoaded){
binding.bioEditText.setText(uiState.bio)
binding.nameEditText.setText(uiState.name)
model.changesApplied()
}
binding.progressCard.visibility = if(uiState.loadingProfile || uiState.sendingProfile || uiState.profileSent || uiState.error) View.VISIBLE else View.INVISIBLE
if(uiState.loadingProfile) binding.progressText.setText(R.string.fetching_profile)
else if(uiState.sendingProfile) binding.progressText.setText(R.string.saving_profile)
binding.privateSwitch.isChecked = uiState.privateAccount == true
Glide.with(binding.profilePic).load(uiState.profilePictureUri)
.apply(RequestOptions.circleCropTransform())
.into(binding.profilePic)
binding.savingProgressBar.visibility =
if(uiState.error || (uiState.profileSent && !uiState.uploadingPicture)) View.GONE
else View.VISIBLE
binding.savingProgressBar.visibility = if(uiState.error || uiState.profileSent) View.GONE
else View.VISIBLE
if(uiState.profileSent && !uiState.uploadingPicture && !uiState.error){
if(uiState.profileSent){
binding.progressText.setText(R.string.profile_saved)
binding.done.visibility = View.VISIBLE
} else {
@ -111,18 +112,18 @@ class EditProfileActivity : BaseActivity() {
}
}
binding.profilePic.setOnClickListener {
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*"))
action = Intent.ACTION_GET_CONTENT
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
uploadImageResultContract.launch(
Intent.createChooser(this, null)
)
}
}
// binding.changeImageButton.setOnClickListener {
// Intent(Intent.ACTION_GET_CONTENT).apply {
// type = "*/*"
// putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*"))
// action = Intent.ACTION_GET_CONTENT
// addCategory(Intent.CATEGORY_OPENABLE)
// putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
// uploadImageResultContract.launch(
// Intent.createChooser(this, null)
// )
// }
// }
}
private val uploadImageResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@ -136,10 +137,10 @@ class EditProfileActivity : BaseActivity() {
val imageUri: String = clipData.getItemAt(i).uri.toString()
images.add(imageUri)
}
model.updateImage(images.first())
model.uploadImage(images.first())
} else if (data.data != null) {
images.add(data.data!!.toString())
model.updateImage(images.first())
model.uploadImage(images.first())
}
}
}

@ -1,16 +1,16 @@
package org.pixeldroid.app.profile
import android.content.Context
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import android.text.Editable
import android.util.Log
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
@ -21,33 +21,23 @@ import kotlinx.coroutines.launch
import okhttp3.MultipartBody
import org.pixeldroid.app.postCreation.ProgressRequestBody
import org.pixeldroid.app.posts.fromHtml
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.updateUserInfoDb
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import retrofit2.HttpException
import javax.inject.Inject
@HiltViewModel
class EditProfileViewModel @Inject constructor(
@ApplicationContext private val applicationContext: Context
): ViewModel() {
class EditProfileViewModel(application: Application) : AndroidViewModel(application) {
@Inject
lateinit var apiHolder: PixelfedAPIHolder
@Inject
lateinit var db: AppDatabase
private val _uiState = MutableStateFlow(EditProfileActivityUiState())
val uiState: StateFlow<EditProfileActivityUiState> = _uiState
private var oldProfile: Account? = null
var submittedChanges = false
private set
var oldProfile: Account? = null
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
loadProfile()
}
@ -56,7 +46,6 @@ class EditProfileViewModel @Inject constructor(
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
try {
val profile = api.verifyCredentials()
updateUserInfoDb(db, profile)
if (oldProfile == null) oldProfile = profile
_uiState.update { currentUiState ->
currentUiState.copy(
@ -87,10 +76,15 @@ class EditProfileViewModel @Inject constructor(
fun sendProfile() {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val requestBody =
null //MultipartBody.Part.createFormData("avatar", System.currentTimeMillis().toString(), avatarBody)
_uiState.update { currentUiState ->
currentUiState.copy(
sendingProfile = true,
profileSent = false,
loadingProfile = false,
profileLoaded = false,
error = false
)
}
@ -103,17 +97,12 @@ class EditProfileViewModel @Inject constructor(
note = bio,
locked = privateAccount,
)
if (madeChanges()) submittedChanges = true
oldProfile = account
_uiState.update { currentUiState ->
currentUiState.copy(
bio = account.source?.note
?: account.note?.let { fromHtml(it).toString() },
bio = account.source?.note ?: account.note?.let {fromHtml(it).toString()},
name = account.display_name,
profilePictureUri = if (profilePictureChanged) profilePictureUri
else account.anyAvatar()?.toUri(),
uploadProgress = 0,
uploadingPicture = profilePictureChanged,
profilePictureUri = account.anyAvatar()?.toUri(),
privateAccount = account.locked,
sendingProfile = false,
profileSent = true,
@ -122,13 +111,14 @@ class EditProfileViewModel @Inject constructor(
error = false
)
}
if(profilePictureChanged) uploadImage()
} catch (exception: Exception) {
Log.e("TAG", exception.toString())
_uiState.update { currentUiState ->
currentUiState.copy(
sendingProfile = false,
profileSent = false,
loadingProfile = false,
profileLoaded = false,
error = true
)
}
@ -155,16 +145,20 @@ class EditProfileViewModel @Inject constructor(
}
}
fun changesApplied() {
_uiState.update { currentUiState ->
currentUiState.copy(profileLoaded = false)
}
}
fun madeChanges(): Boolean =
with(uiState.value) {
val privateChanged = oldProfile?.locked != privateAccount
val displayNameChanged = oldProfile?.display_name != name
val bioChanged: Boolean = oldProfile?.source?.note?.let { it != bio }
// If source note is null, check note
val bioUnchanged: Boolean = oldProfile?.source?.note?.let { it != bio }
// If source note is null, check note
?: oldProfile?.note?.let { fromHtml(it).toString() != bio }
?: true
profilePictureChanged || privateChanged || displayNameChanged || bioChanged
oldProfile?.locked != privateAccount || oldProfile?.display_name != name
|| bioUnchanged
}
fun clickedCard() {
@ -184,27 +178,16 @@ class EditProfileViewModel @Inject constructor(
}
}
fun updateImage(image: String) {
_uiState.update { currentUiState ->
currentUiState.copy(
profilePictureUri = image.toUri(),
profilePictureChanged = true,
profileSent = false
)
}
}
private fun uploadImage() {
val image = uiState.value.profilePictureUri!!
fun uploadImage(image: String) {
//TODO fix
val inputStream =
applicationContext.contentResolver.openInputStream(image)
getApplication<PixelDroidApplication>().contentResolver.openInputStream(image.toUri())
?: return
val size: Long =
if (image.scheme == "content") {
applicationContext.contentResolver.query(
image,
if (image.toUri().scheme == "content") {
getApplication<PixelDroidApplication>().contentResolver.query(
image.toUri(),
null,
null,
null,
@ -220,7 +203,7 @@ class EditProfileViewModel @Inject constructor(
cursor.getLong(sizeIndex)
} ?: 0
} else {
image.toFile().length()
image.toUri().toFile().length()
}
val imagePart = ProgressRequestBody(inputStream, size, "image/*")
@ -242,32 +225,21 @@ class EditProfileViewModel @Inject constructor(
var postSub: Disposable? = null
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
val pixelfed = db.instanceDao().getActiveInstance().pixelfed
val inter =
if(pixelfed) api.updateProfilePicture(requestBody.parts[0])
else api.updateProfilePictureMastodon(requestBody.parts[0])
val inter = api.updateProfilePicture(requestBody.parts[0])
postSub = inter
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
/* onNext = */ { account: Account ->
account.anyAvatar()?.let {
_uiState.update { currentUiState ->
currentUiState.copy(
profilePictureUri = it.toUri()
)
}
}
{ it: Account ->
Log.e("qsdfqsdfs", it.toString())
},
/* onError = */ { e: Throwable ->
Log.e("error", (e as? HttpException)?.message().orEmpty())
{ e: Throwable ->
_uiState.update { currentUiState ->
currentUiState.copy(
uploadProgress = 0,
uploadingPicture = false,
uploadingPicture = true,
error = true
)
}
@ -275,10 +247,9 @@ class EditProfileViewModel @Inject constructor(
postSub?.dispose()
sub.dispose()
},
/* onComplete = */ {
{
_uiState.update { currentUiState ->
currentUiState.copy(
profilePictureChanged = false,
uploadProgress = 100,
uploadingPicture = false
)
@ -294,8 +265,7 @@ class EditProfileViewModel @Inject constructor(
data class EditProfileActivityUiState(
val name: String? = null,
val bio: String? = null,
val profilePictureUri: Uri? = null,
val profilePictureChanged: Boolean = false,
val profilePictureUri: Uri?= null,
val privateAccount: Boolean? = null,
val loadingProfile: Boolean = true,
val profileLoaded: Boolean = false,
@ -304,4 +274,10 @@ data class EditProfileActivityUiState(
val error: Boolean = false,
val uploadingPicture: Boolean = false,
val uploadProgress: Int = 0,
)
)
class EditProfileViewModelFactory(val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java).newInstance(application)
}
}

@ -1,8 +1,6 @@
package org.pixeldroid.app.profile
import android.os.Bundle
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
@ -14,8 +12,10 @@ import org.pixeldroid.app.utils.api.objects.Account.Companion.FOLLOWERS_TAG
class FollowsActivity : BaseActivity() {
private var followsFragment = AccountListFragment()
private lateinit var binding: ActivityFollowersBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFollowersBinding.inflate(layoutInflater)
@ -47,10 +47,10 @@ class FollowsActivity : BaseActivity() {
val arguments = Bundle()
arguments.putSerializable(ACCOUNT_ID_TAG, id)
arguments.putSerializable(FOLLOWERS_TAG, followers)
followsFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.followsFragment, followsFragment).commit()
supportFragmentManager.commit {
setReorderingAllowed(true)
replace<AccountListFragment>(R.id.conversationFragment, args = arguments)
}
}
}

@ -6,28 +6,21 @@ import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityProfileBinding
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedFeedFragment
import org.pixeldroid.app.posts.parseHTMLText
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.db.updateUserInfoDb
import org.pixeldroid.app.utils.setProfileImageFromURL
import retrofit2.HttpException
import java.io.IOException
@ -61,32 +54,9 @@ class ProfileActivity : BaseActivity() {
val tabs = createProfileTabs(account)
setupTabs(tabs)
setContent(account)
binding.profileMotion.setTransitionListener(
object : MotionLayout.TransitionListener {
override fun onTransitionStarted(
motionLayout: MotionLayout?, startId: Int, endId: Int,
) {}
override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) {}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
if (currentId == R.id.hideProfile && motionLayout?.startState == R.id.start) {
// If the 1st transition has been made go to the second one
motionLayout.setTransition(R.id.second)
} else if(currentId == R.id.hideProfile && motionLayout?.startState == R.id.hideProfile){
motionLayout.setTransition(R.id.first)
}
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float,
) {}
}
)
}
private fun createProfileTabs(account: Account?): Array<UncachedFeedFragment<FeedContent>> {
private fun createProfileTabs(account: Account?): Array<Fragment>{
val profileFeedFragment = ProfileFeedFragment()
profileFeedFragment.arguments = Bundle().apply {
@ -110,7 +80,7 @@ class ProfileActivity : BaseActivity() {
putSerializable(ProfileFeedFragment.COLLECTIONS, true)
}
val returnArray: Array<UncachedFeedFragment<FeedContent>> = arrayOf(
val returnArray: Array<Fragment> = arrayOf(
profileGridFragment,
profileFeedFragment,
profileCollectionsFragment
@ -130,7 +100,7 @@ class ProfileActivity : BaseActivity() {
}
private fun setupTabs(
tabs: Array<UncachedFeedFragment<FeedContent>>,
tabs: Array<Fragment>
){
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
override fun createFragment(position: Int): Fragment {
@ -162,16 +132,9 @@ class ProfileActivity : BaseActivity() {
}
}
}.attach()
binding.profileTabs.addOnTabSelectedListener(object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {
tabs[tab.position].onTabReClicked()
}
})
}
private fun setContent(account: Account?) {
if(account != null) {
setViews(account)
@ -189,9 +152,6 @@ class ProfileActivity : BaseActivity() {
).show()
return@launchWhenResumed
}
updateUserInfoDb(db, myAccount)
setViews(myAccount)
}
}
@ -257,15 +217,9 @@ class ProfileActivity : BaseActivity() {
)
}
private val editResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
// Profile was edited, reload
setContent(null)
}
}
private fun onClickEditButton() {
editResult.launch(Intent(this, EditProfileActivity::class.java))
val intent = Intent(this, EditProfileActivity::class.java)
ContextCompat.startActivity(this, intent, null)
}
private fun onClickFollowers(account: Account?) {

@ -101,7 +101,7 @@ class ProfileFeedFragment : UncachedFeedFragment<FeedContent>() {
val view = super.onCreateView(inflater, container, savedInstanceState)
if(grid || bookmarks || collections || addCollection) {
binding?.list?.layoutManager = GridLayoutManager(context, 3)
binding.list.layoutManager = GridLayoutManager(context, 3)
}
// Get the view model
@ -191,11 +191,8 @@ class ProfileFeedFragment : UncachedFeedFragment<FeedContent>() {
val url = "$domain/i/collections/create"
if(domain.isNullOrEmpty() || !requireContext().openUrl(url)) {
binding?.let { binding ->
Snackbar.make(
binding.root, getString(R.string.new_collection_link_failed),
Snackbar.LENGTH_LONG).show()
}
Snackbar.make(binding.root, getString(R.string.new_collection_link_failed),
Snackbar.LENGTH_LONG).show()
}
}

@ -1,257 +0,0 @@
package org.pixeldroid.app.settings
import android.annotation.SuppressLint
import android.app.Dialog
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.button.MaterialButton
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.LayoutTabsArrangeBinding
import org.pixeldroid.app.utils.Tab
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
import javax.inject.Inject
@AndroidEntryPoint
class ArrangeTabsFragment: DialogFragment() {
private lateinit var binding: LayoutTabsArrangeBinding
@Inject
lateinit var db: AppDatabase
private val model: ArrangeTabsViewModel by viewModels()
var showTutorial = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = LayoutTabsArrangeBinding.inflate(layoutInflater)
val itemCount = model.initTabsChecked()
model.initTabsButtons(itemCount, requireContext())
val listFeed: RecyclerView = binding.tabs
val listAdapter = ListViewAdapter(model)
listFeed.adapter = listAdapter
listFeed.layoutManager = LinearLayoutManager(requireActivity())
val callback = object: ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
listAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Do nothing, all items should remain in the list
}
}
val itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper.attachToRecyclerView(listFeed)
val dialog = MaterialAlertDialogBuilder(requireContext()).apply {
setIcon(R.drawable.outline_bottom_navigation)
setTitle(R.string.arrange_tabs_summary)
setView(binding.root)
setNegativeButton(android.R.string.cancel) { _, _ -> }
setPositiveButton(android.R.string.ok) { _, _ ->
// Save values into preferences
val tabsChecked = listAdapter.model.uiState.value.tabsChecked.toList()
val tabsDbEntity = tabsChecked.mapIndexed { index, (tab, checked) -> with (db.userDao().getActiveUser()!!) {
TabsDatabaseEntity(index, user_id, instance_uri, tab.name, checked, tab.filter)
} }
lifecycleScope.launch {
db.tabsDao().clearAndRefill(tabsDbEntity, model.uiState.value.userId, model.uiState.value.instanceUri)
}
}
}.create()
if (showTutorial) showTutorial(dialog)
return dialog
}
private fun showTutorial(dialog: Dialog){
lifecycleScope.launch {
var handle = binding.tabs.findViewHolderForLayoutPosition(0)?.itemView?.findViewById<ImageView>(R.id.dragHandle)
while (handle == null) {
handle = binding.tabs.findViewHolderForLayoutPosition(0)?.itemView?.findViewById(R.id.dragHandle)
delay(100)
}
TapTargetView.showFor(
dialog,
TapTarget.forView(handle, getString(R.string.drag_customtabs_tutorial))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
val checkBox = binding.tabs.findViewHolderForLayoutPosition(0)?.itemView?.findViewById<View>(R.id.checkBox)
TapTargetView.showFor(
dialog,
TapTarget.forView(checkBox,
getString(R.string.de_activate_tabs_tutorial))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
val index = (Tab.defaultTabs + Tab.otherTabs).size - 1
binding.tabs.scrollToPosition(index)
lifecycleScope.launch {
var hashtag =
binding.tabs.findViewHolderForLayoutPosition(index)?.itemView?.findViewById<View>(
R.id.textView
)
while (hashtag == null) {
hashtag =
binding.tabs.findViewHolderForLayoutPosition(index)?.itemView?.findViewById(
R.id.textView
)
delay(100)
}
TapTargetView.showFor(
dialog,
TapTarget.forView(
hashtag,
getString(R.string.custom_feed_tutorial_title),
getString(R.string.custom_feed_tutorial_explanation)
)
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
}
})
}
}
})
}
})
}
}
inner class ListViewAdapter(val model: ArrangeTabsViewModel):
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = FrameLayout.inflate(context, R.layout.layout_tab, null)
// Make sure the layout occupies full width
view.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
return object: RecyclerView.ViewHolder(view) {}
}
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val textView: MaterialButton = holder.itemView.findViewById(R.id.textView)
val checkBox: MaterialCheckBox = holder.itemView.findViewById(R.id.checkBox)
val dragHandle: ImageView = holder.itemView.findViewById(R.id.dragHandle)
val tabText = model.getTabsButtons(position)
// Set content of each entry
if (tabText != null) {
textView.text = tabText
} else {
model.updateTabsButtons(position, model.uiState.value.tabsChecked[position].first.toLanguageString(requireContext(), db, position, true))
textView.text = model.getTabsButtons(position)
}
checkBox.isChecked = model.uiState.value.tabsChecked[position].second
fun callbackOnClickListener(tabNew: Tab, isCheckedNew: Boolean, hashtag: String? = null) {
tabNew.filter = hashtag?.split("#")?.filter { it.isNotBlank() }?.get(0)?.trim()
model.tabsCheckReplace(position, Pair(tabNew, isCheckedNew))
checkBox.isChecked = model.uiState.value.tabsChecked[position].second
val hashtagWithHashtag = tabNew.filter?.let {
StringBuilder("#").append(it).toString()
}
// Disable OK button when no tab is selected or when strictly more than 5 tabs are selected
val maxItemCount = BottomNavigationView(requireContext()).maxItemCount // = 5
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
with (model.uiState.value.tabsChecked.count { (_, v) -> v }) { this in 1..maxItemCount}
model.updateTabsButtons(position, hashtagWithHashtag ?: model.uiState.value.tabsChecked[position].first.toLanguageString(requireContext(), db, position, false))
textView.text = model.getTabsButtons(position)
}
// Also interact with checkbox when button is clicked
textView.setOnClickListener {
val isCheckedNew = !model.uiState.value.tabsChecked[position].second
val tabNew = model.uiState.value.tabsChecked[position].first
if (tabNew == Tab.HASHTAG_FEED && isCheckedNew) {
// Ask which hashtag should filter
val textField = EditText(requireContext())
textField.hint = "hashtag"
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.feed_hashtag))
.setMessage(getString(R.string.feed_hashtag_description))
.setView(textField)
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setPositiveButton(android.R.string.ok) { _, _ ->
val hashtag = textField.text.toString()
callbackOnClickListener(tabNew, isCheckedNew, hashtag)
}
.show()
} else {
callbackOnClickListener(tabNew, isCheckedNew)
}
}
// Also highlight button when checkbox is clicked
checkBox.setOnTouchListener { _, motionEvent ->
textView.dispatchTouchEvent(motionEvent)
}
// Do not highlight the button when the drag handle is touched
dragHandle.setOnTouchListener { _, _ -> true }
}
override fun getItemCount(): Int {
return model.uiState.value.tabsChecked.size
}
fun onItemMove(from: Int, to: Int) {
val previous = model.tabsCheckedRemove(from)
model.tabsCheckedAdd(to, previous)
model.swapTabsButtons(from, to)
notifyItemMoved(from, to)
notifyItemChanged(to) // necessary to avoid checkBox issues
}
}
}

@ -1,141 +0,0 @@
package org.pixeldroid.app.settings
import android.content.Context
import android.widget.EditText
import androidx.lifecycle.ViewModel
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.pixeldroid.app.utils.Tab
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
import org.pixeldroid.app.utils.loadDbMenuTabs
import javax.inject.Inject
@HiltViewModel
class ArrangeTabsViewModel @Inject constructor(
private val db: AppDatabase
): ViewModel() {
private val _uiState = MutableStateFlow(ArrangeTabsUiState())
val uiState: StateFlow<ArrangeTabsUiState> = _uiState
private var oldTabsChecked: MutableList<Pair<Tab, Boolean>> = mutableListOf()
private var oldTabsButtons: MutableList<String?> = mutableListOf()
init {
initTabsDbEntities()
}
private fun initTabsDbEntities() {
val user = db.userDao().getActiveUser()!!
_uiState.update { currentUiState ->
currentUiState.copy(
userId = user.user_id,
instanceUri = user.instance_uri,
)
}
_uiState.update { currentUiState ->
currentUiState.copy(
tabsDbEntities = db.tabsDao().getTabsChecked(_uiState.value.userId, _uiState.value.instanceUri)
)
}
}
fun initTabsChecked(): Int {
if (oldTabsChecked.isEmpty()) {
// Only load tabsChecked if the model has not been updated
_uiState.update { currentUiState ->
currentUiState.copy(
tabsChecked = if (_uiState.value.tabsDbEntities.isEmpty()) {
// Load default menu
Tab.defaultTabs.zip(List(Tab.defaultTabs.size){true}) + Tab.otherTabs.zip(List(Tab.otherTabs.size){false})
} else {
// Get current menu visibility and order from settings
loadDbMenuTabs(_uiState.value.tabsDbEntities).toList()
}
)
}
}
return _uiState.value.tabsChecked.size
}
fun initTabsButtons(itemCount: Int, ctx: Context) {
oldTabsChecked = _uiState.value.tabsChecked.toMutableList()
if (oldTabsButtons.isEmpty()) {
_uiState.update { currentUiState ->
currentUiState.copy(
tabsButtons = (0 until itemCount).map { null }
)
}
}
}
fun tabsCheckReplace(position: Int, pair: Pair<Tab, Boolean>) {
oldTabsChecked = _uiState.value.tabsChecked.toMutableList()
oldTabsChecked[position] = pair
_uiState.update { currentUiState ->
currentUiState.copy(
tabsChecked = oldTabsChecked.toList()
)
}
}
fun tabsCheckedRemove(position: Int): Pair<Tab, Boolean> {
oldTabsChecked = _uiState.value.tabsChecked.toMutableList()
val removedPair = oldTabsChecked.removeAt(position)
_uiState.update { currentUiState ->
currentUiState.copy(
tabsChecked = oldTabsChecked.toList()
)
}
return removedPair
}
fun tabsCheckedAdd(position: Int, pair: Pair<Tab, Boolean>) {
oldTabsChecked = _uiState.value.tabsChecked.toMutableList()
oldTabsChecked.add(position, pair)
_uiState.update { currentUiState ->
currentUiState.copy(
tabsChecked = oldTabsChecked.toList()
)
}
}
fun updateTabsButtons(position: Int, text: String) {
oldTabsButtons = _uiState.value.tabsButtons.toMutableList()
oldTabsButtons[position] = text
_uiState.update { currentUiState ->
currentUiState.copy(
tabsButtons = oldTabsButtons.toList()
)
}
}
fun getTabsButtons(position: Int): String? {
return _uiState.value.tabsButtons[position]
}
fun swapTabsButtons(from: Int, to: Int) {
oldTabsButtons = _uiState.value.tabsButtons.toMutableList()
val fromText = oldTabsButtons[from]
oldTabsButtons[from] = oldTabsButtons[to]
oldTabsButtons[to] = fromText
_uiState.update { currentUiState ->
currentUiState.copy(
tabsButtons = oldTabsButtons.toList()
)
}
}
}
data class ArrangeTabsUiState(
val userId: String = "",
val instanceUri: String = "",
val tabsDbEntities: List<TabsDatabaseEntity> = listOf(),
val tabsChecked: List<Pair<Tab, Boolean>> = listOf(),
val tabsButtons: List<String?> = listOf()
)

@ -1,56 +0,0 @@
package org.pixeldroid.app.settings
import android.app.Dialog
import android.content.res.XmlResourceParser
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R
class LanguageSettingFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val list: MutableList<String> = mutableListOf()
// IDE doesn't find it, but compiling works apparently?
resources.getXml(R.xml._generated_res_locale_config).use {
var eventType = it.eventType
while (eventType != XmlResourceParser.END_DOCUMENT) {
when (eventType) {
XmlResourceParser.START_TAG -> {
if (it.name == "locale") {
list.add(it.getAttributeValue(0))
}
}
}
eventType = it.next()
}
}
val locales = AppCompatDelegate.getApplicationLocales()
val checkedItem: Int =
if(locales.isEmpty) 0
else {
// For some reason we get a bit inconsistent language tags. This normalises it for
// the currently used languages, but it might break in the future if we add some
val index = list.indexOf(locales.get(0)?.toLanguageTag()?.lowercase()?.replace('_', '-'))
// If found, we want to compensate for the first in the list being the default
if(index == -1) -1
else index + 1
}
return MaterialAlertDialogBuilder(requireContext()).apply {
setIcon(R.drawable.translate_black_24dp)
setTitle(R.string.language)
setSingleChoiceItems((mutableListOf(getString(R.string.default_system)) + list.map {
val appLocale = LocaleListCompat.forLanguageTags(it)
appLocale.get(0)!!.getDisplayName(appLocale.get(0)!!)
}).toTypedArray(), checkedItem) { dialog, which ->
val languageTag = if(which in 1..list.size) list[which - 1] else null
dialog.dismiss()
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageTag))
}
setNegativeButton(android.R.string.ok) { _, _ -> }
}.create()
}
}

@ -1,78 +1,48 @@
package org.pixeldroid.app.settings
import android.app.Dialog
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.XmlResourceParser
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.SettingsBinding
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.settings.TutorialSettingsDialog.Companion.START_TUTORIAL
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.CommonWrapper
import org.pixeldroid.common.ThemedActivity
import org.pixeldroid.app.utils.setThemeFromPreferences
@AndroidEntryPoint
class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
class SettingsActivity : ThemedActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
private var restartMainOnExit = false
private lateinit var binding: SettingsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = SettingsBinding.inflate(layoutInflater)
val binding = SettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.topBar)
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment(), "topsettingsfragment")
.replace(R.id.settings, SettingsFragment())
.commit()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val showTutorial = intent.getBooleanExtra(START_TUTORIAL, false)
if(showTutorial){
lifecycleScope.launch {
var target =
(supportFragmentManager.findFragmentByTag("topsettingsfragment") as? SettingsFragment)?.scrollToArrangeTabs(10)
while (target == null) {
target = (supportFragmentManager.findFragmentByTag("topsettingsfragment") as? SettingsFragment)?.scrollToArrangeTabs(10)
delay(100)
}
target.performClick()
}
}
onBackPressedDispatcher.addCallback(this /* lifecycle owner */) {
// Handle the back button event
// If a setting (for example language or theme) was changed, the main activity should be
// started without history so that the change is applied to the whole back stack
//TODO restore behaviour without true here, so that MainActivity is not destroyed when not necessary
// The true is a "temporary" (lol) fix so that tab changes are always taken into account
// Also, consider making the up button (arrow in action bar) also take this codepath!
// It recreates the activity by default
if (true || restartMainOnExit) {
if (restartMainOnExit) {
val intent = Intent(this@SettingsActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
super@SettingsActivity.startActivity(intent)
@ -109,27 +79,6 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
"themeColor" -> {
recreateWithRestartStatus()
}
"always_show_nsfw" -> {
lifecycleScope.launch {
val api: PixelfedAPI = apiHolder.api ?: apiHolder.setToCurrentUser()
try {
// Get old settings and modify just the nsfw one
val settings = api.getSettings().let { settings ->
settings.common.copy(
media = settings.common.media.copy(
always_show_cw = sharedPreferences.getBoolean(key, settings.common.media.always_show_cw)
)
)
}
api.setSettings(CommonWrapper(settings))
} catch (e: Exception) {
Log.e("Pixelfed API settings", e.toString())
}
}
}
else -> {}
}
}
}
@ -147,36 +96,6 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
super.startActivity(intent)
}
fun customTabsTutorial(){
lifecycleScope.launch {
var target =
(supportFragmentManager.findFragmentByTag("topsettingsfragment") as? SettingsFragment)?.scrollToArrangeTabs(5)
while (target == null) {
target = (supportFragmentManager.findFragmentByTag("topsettingsfragment") as? SettingsFragment)?.scrollToArrangeTabs(5)
delay(100)
}
TapTargetView.showFor(
this@SettingsActivity,
TapTarget.forView(target, getString(R.string.arrange_tabs_tutorial_title))
.transparentTarget(true)
.targetRadius(60), // Specify the target radius (in dp)
object : TapTargetView.Listener() {
// The listener can listen for regular clicks, long clicks or cancels
override fun onTargetClick(view: TapTargetView?) {
super.onTargetClick(view) // This call is optional
// Perform action for the current target
val dialogFragment = ArrangeTabsFragment().apply { showTutorial = true }
dialogFragment.setTargetFragment(
(supportFragmentManager.findFragmentByTag("topsettingsfragment") as? SettingsFragment),
0
)
dialogFragment.show(supportFragmentManager, "settings_fragment")
}
})
}
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onDisplayPreferenceDialog(preference: Preference) {
var dialogFragment: DialogFragment? = null
@ -184,10 +103,6 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
dialogFragment = ColorPreferenceDialog((preference as ColorPreference?)!!)
} else if(preference.key == "language"){
dialogFragment = LanguageSettingFragment()
} else if (preference.key == "arrange_tabs") {
dialogFragment = ArrangeTabsFragment()
} else if (preference.key == "tutorial") {
dialogFragment = TutorialSettingsDialog()
}
if (dialogFragment != null) {
dialogFragment.setTargetFragment(this, 0)
@ -196,16 +111,6 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
super.onDisplayPreferenceDialog(preference)
}
}
fun scrollToArrangeTabs(position: Int): View? {
//Hardcoded positions because it's too annoying to find!
if (listView != null && position != -1) {
listView.post {
listView.smoothScrollToPosition(position)
}
}
return listView.findViewHolderForAdapterPosition(position)?.itemView
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
@ -216,16 +121,6 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
locale?.getDisplayName(locale) ?: getString(R.string.default_system)
}
}
findPreference<CheckBoxPreference>("always_show_nsfw")?.let {
lifecycleScope.launch {
val api: PixelfedAPI = (requireActivity() as SettingsActivity).apiHolder.api ?: (requireActivity() as SettingsActivity).apiHolder.setToCurrentUser()
try {
val show = api.getSettings().common.media.always_show_cw
it.isChecked = show
} catch (_: Exception){}
}
}
//Hide Notification setting for Android versions where it doesn't work
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@ -234,4 +129,49 @@ class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceCha
}
}
}
}
}
class LanguageSettingFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val list: MutableList<String> = mutableListOf()
// IDE doesn't find it, but compiling works apparently?
resources.getXml(R.xml._generated_res_locale_config).use {
var eventType = it.eventType
while (eventType != XmlResourceParser.END_DOCUMENT) {
when (eventType) {
XmlResourceParser.START_TAG -> {
if (it.name == "locale") {
list.add(it.getAttributeValue(0))
}
}
}
eventType = it.next()
}
}
val locales = AppCompatDelegate.getApplicationLocales()
val checkedItem: Int =
if(locales.isEmpty) 0
else {
// For some reason we get a bit inconsistent language tags. This normalises it for
// the currently used languages, but it might break in the future if we add some
val index = list.indexOf(locales.get(0)?.toLanguageTag()?.lowercase()?.replace('_', '-'))
// If found, we want to compensate for the first in the list being the default
if(index == -1) -1
else index + 1
}
return MaterialAlertDialogBuilder(requireContext()).apply {
setIcon(R.drawable.translate_black_24dp)
setTitle(R.string.language)
setSingleChoiceItems((mutableListOf(getString(R.string.default_system)) + list.map {
val appLocale = LocaleListCompat.forLanguageTags(it)
appLocale.get(0)!!.getDisplayName(appLocale.get(0)!!)
}).toTypedArray(), checkedItem) { dialog, which ->
val languageTag = if(which in 1..list.size) list[which - 1] else null
dialog.dismiss()
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageTag))
}
setNegativeButton(android.R.string.ok) { _, _ -> }
}.create()
}
}

@ -1,77 +0,0 @@
package org.pixeldroid.app.settings;
import android.app.Dialog
import android.content.Intent
import android.graphics.Typeface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import com.getkeepsafe.taptargetview.TapTarget
import com.getkeepsafe.taptargetview.TapTargetView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.utils.Tab
class TutorialSettingsDialog: DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = arrayOf(
Pair(R.string.feeds_tutorial, R.drawable.ic_home_white_24dp),
Pair(R.string.create_tutorial, R.drawable.photo_camera),
Pair(R.string.dm_tutorial, R.drawable.message),
Pair(R.string.custom_tabs_tutorial, R.drawable.outline_bottom_navigation)
)
val adapter = object : ArrayAdapter<Pair<Int, Int>>(requireContext(), android.R.layout.simple_list_item_1, items) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view: TextView = if (convertView == null) {
LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, parent, false) as TextView
} else {
convertView as TextView
}
val item = getItem(position)
if (item != null) {
view.setText(item.first)
view.setTypeface(null, Typeface.NORMAL) // Set the typeface to normal
view.setCompoundDrawablesWithIntrinsicBounds(item.second, 0, 0, 0)
view.compoundDrawablePadding = 16 // Add padding between text and drawable
}
view.setPadding(0, 32, 0, 32)
return view
}
}
return MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.tutorial_choice))
.setAdapter(adapter) { _, which ->
if(which == 3){
customTabsTutorial()
return@setAdapter
}
val intent = Intent(requireContext(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(START_TUTORIAL, which)
startActivity(intent)
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.create()
}
private fun customTabsTutorial() {
(requireActivity() as SettingsActivity).customTabsTutorial()
}
companion object {
const val START_TUTORIAL = "tutorial_start_intent"
}
}

@ -25,6 +25,9 @@ import org.pixeldroid.app.databinding.ActivityStoriesBinding
import org.pixeldroid.app.posts.setTextViewFromISO8601
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Account
import org.pixeldroid.app.utils.api.objects.Story
import org.pixeldroid.app.utils.api.objects.StoryCarousel
class StoriesActivity: BaseActivity() {
@ -34,11 +37,12 @@ class StoriesActivity: BaseActivity() {
const val STORY_CAROUSEL_USER_ID = "LaunchStoryUserId"
}
private lateinit var binding: ActivityStoriesBinding
private lateinit var storyProgress: StoryProgress
private val model: StoriesViewModel by viewModels()
private lateinit var model: StoriesViewModel
override fun onCreate(savedInstanceState: Bundle?) {
//force night mode always
@ -46,9 +50,18 @@ class StoriesActivity: BaseActivity() {
super.onCreate(savedInstanceState)
val carousel = intent.getSerializableExtra(STORY_CAROUSEL) as? StoryCarousel
val userId = intent.getStringExtra(STORY_CAROUSEL_USER_ID)
val selfCarousel: Array<Story>? = intent.getSerializableExtra(STORY_CAROUSEL_SELF) as? Array<Story>
binding = ActivityStoriesBinding.inflate(layoutInflater)
setContentView(binding.root)
val _model: StoriesViewModel by viewModels {
StoriesViewModelFactory(application, carousel, userId, selfCarousel?.asList())
}
model = _model
storyProgress = StoryProgress(model.uiState.value.imageList.size)
binding.storyProgressImage.setImageDrawable(storyProgress)

@ -1,18 +1,20 @@
package org.pixeldroid.app.stories
import android.app.Application
import android.os.CountDownTimer
import android.text.Editable
import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.CarouselUserContainer
import org.pixeldroid.app.utils.api.objects.Story
import org.pixeldroid.app.utils.api.objects.StoryCarousel
@ -35,13 +37,18 @@ data class StoriesUiState(
val snackBar: Int? = null,
val reply: String = ""
)
@HiltViewModel
class StoriesViewModel @Inject constructor(state: SavedStateHandle,
db: AppDatabase,
private val apiHolder: PixelfedAPIHolder) : ViewModel() {
private val carousel: StoryCarousel? = state[StoriesActivity.STORY_CAROUSEL]
private val userId: String? = state[StoriesActivity.STORY_CAROUSEL_USER_ID]
private val selfCarousel: Array<Story>? = state[StoriesActivity.STORY_CAROUSEL_SELF]
class StoriesViewModel(
application: Application,
val carousel: StoryCarousel?,
userId: String?,
val selfCarousel: List<Story>?
) : AndroidViewModel(application) {
@Inject
lateinit var apiHolder: PixelfedAPIHolder
@Inject
lateinit var db: AppDatabase
private var currentAccount: CarouselUserContainer?
@ -54,9 +61,10 @@ class StoriesViewModel @Inject constructor(state: SavedStateHandle,
private var timer: CountDownTimer? = null
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
currentAccount =
if (selfCarousel != null) {
db.userDao().getActiveUser()?.let { CarouselUserContainer(it, selfCarousel.toList()) }
db.userDao().getActiveUser()?.let { CarouselUserContainer(it, selfCarousel) }
} else carousel?.nodes?.firstOrNull { it?.user?.id == userId }
_uiState = MutableStateFlow(newUiStateFromCurrentAccount())
@ -208,3 +216,14 @@ class StoriesViewModel @Inject constructor(state: SavedStateHandle,
fun currentProfileId(): String? = currentAccount?.user?.id
}
class StoriesViewModelFactory(
val application: Application,
val carousel: StoryCarousel?,
val userId: String?,
val selfCarousel: List<Story>?
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java, StoryCarousel::class.java, String::class.java, List::class.java).newInstance(application, carousel, userId, selfCarousel)
}
}

@ -1,11 +1,10 @@
package org.pixeldroid.app.utils
import dagger.hilt.android.AndroidEntryPoint
import android.os.Bundle
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import javax.inject.Inject
@AndroidEntryPoint
open class BaseActivity : org.pixeldroid.common.ThemedActivity() {
@Inject
@ -13,6 +12,11 @@ open class BaseActivity : org.pixeldroid.common.ThemedActivity() {
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(this.application as PixelDroidApplication).getAppComponent().inject(this)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true

@ -1,9 +1,9 @@
package org.pixeldroid.app.utils
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
@ -12,7 +12,6 @@ import javax.inject.Inject
/**
* Base Fragment, for dependency injection and other things common to a lot of the fragments
*/
@AndroidEntryPoint
open class BaseFragment: Fragment() {
@Inject
@ -21,6 +20,11 @@ open class BaseFragment: Fragment() {
@Inject
lateinit var db: AppDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(requireActivity().application as PixelDroidApplication).getAppComponent().inject(this)
}
internal val requestPermissionDownloadPic =
registerForActivityResult(
ActivityResultContracts.RequestPermission()

@ -1,29 +1,15 @@
package org.pixeldroid.app.utils
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.preference.PreferenceManager
import androidx.work.Configuration
import com.google.android.material.color.DynamicColors
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import org.ligi.tracedroid.TraceDroid
import javax.inject.Inject
import org.pixeldroid.app.utils.di.*
@HiltAndroidApp
class PixelDroidApplication : Application(), Configuration.Provider {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HiltWorkerFactoryEntryPoint {
fun workerFactory(): HiltWorkerFactory
}
override val workManagerConfiguration =
Configuration.Builder()
.setWorkerFactory(EntryPoints.get(this, HiltWorkerFactoryEntryPoint::class.java).workerFactory()) .build()
class PixelDroidApplication: Application() {
private lateinit var mApplicationComponent: ApplicationComponent
override fun onCreate() {
super.onCreate()
@ -33,7 +19,18 @@ class PixelDroidApplication : Application(), Configuration.Provider {
val sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(this)
setThemeFromPreferences(sharedPreferences, resources)
mApplicationComponent = DaggerApplicationComponent
.builder()
.applicationModule(ApplicationModule(this))
.databaseModule(DatabaseModule(applicationContext))
.aPIModule(APIModule())
.build()
mApplicationComponent.inject(this)
DynamicColors.applyToActivitiesIfAvailable(this)
}
fun getAppComponent(): ApplicationComponent {
return mApplicationComponent
}
}

@ -1,27 +1,28 @@
package org.pixeldroid.app.utils
import android.content.ActivityNotFoundException
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.*
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.view.WindowManager
import android.webkit.MimeTypeMap
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.browser.customtabs.CustomTabsIntent
import androidx.exifinterface.media.ExifInterface
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
@ -31,11 +32,9 @@ import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializer
import okhttp3.HttpUrl
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.*
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -148,8 +147,8 @@ fun setThemeFromPreferences(preferences: SharedPreferences, resources: Resources
themes[1] -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
}
//Dark or AMOLED dark
themes[2], themes[3] -> {
//Dark
themes[2] -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
else -> {
@ -196,88 +195,4 @@ fun <T> Fragment.bindingLifecycleAware(): ReadWriteProperty<Fragment, T> =
binding = value
this@bindingLifecycleAware.viewLifecycleOwner.lifecycle.addObserver(this)
}
}
fun loadDbMenuTabs(tabsDbEntry: List<TabsDatabaseEntity>): List<Pair<Tab, Boolean>> {
return tabsDbEntry.map {
val tab = Tab.fromName(it.tab)
if (tab == Tab.HASHTAG_FEED) {
tab.filter = it.filter
}
Pair(tab, it.checked)
}
}
enum class Tab(var filter: String? = null) {
HOME_FEED, SEARCH_DISCOVER_FEED, CREATE_FEED, NOTIFICATIONS_FEED, PUBLIC_FEED, DIRECT_MESSAGES, HASHTAG_FEED;
fun toLanguageString(ctx: Context, db: AppDatabase, index: Int, lookForFilter: Boolean = false): String {
return when (this) {
HOME_FEED -> ctx.getString(R.string.home_feed)
SEARCH_DISCOVER_FEED -> ctx.getString(R.string.search_discover_feed)
CREATE_FEED -> ctx.getString(R.string.create_feed)
NOTIFICATIONS_FEED -> ctx.getString(R.string.notifications_feed)
PUBLIC_FEED -> ctx.getString(R.string.public_feed)
DIRECT_MESSAGES -> ctx.getString(R.string.direct_messages)
HASHTAG_FEED -> {
val user = db.userDao().getActiveUser()!!
val hashtag = db.tabsDao().getTabChecked(index, user.user_id, user.instance_uri)?.filter
if (lookForFilter && hashtag != null) {
StringBuilder("#").append(hashtag).toString()
} else {
ctx.getString(R.string.feed_hashtag)
}
}
}
}
fun toName(): String {
return this.name
}
fun getDrawable(ctx: Context): Drawable? {
val resId = when (this) {
HOME_FEED -> R.drawable.selector_home_feed
SEARCH_DISCOVER_FEED -> R.drawable.ic_search_white_24dp
CREATE_FEED -> R.drawable.selector_camera
NOTIFICATIONS_FEED -> R.drawable.selector_notifications
PUBLIC_FEED -> R.drawable.ic_filter_black_24dp
DIRECT_MESSAGES -> R.drawable.selector_dm
HASHTAG_FEED -> R.drawable.feed_hashtag
}
return AppCompatResources.getDrawable(ctx, resId)
}
companion object {
fun fromLanguageString(ctx: Context, name: String): Tab {
return when (name) {
ctx.getString(R.string.home_feed) -> HOME_FEED
ctx.getString(R.string.search_discover_feed) -> SEARCH_DISCOVER_FEED
ctx.getString(R.string.create_feed) -> CREATE_FEED
ctx.getString(R.string.notifications_feed) -> NOTIFICATIONS_FEED
ctx.getString(R.string.public_feed) -> PUBLIC_FEED
ctx.getString(R.string.direct_messages) -> DIRECT_MESSAGES
ctx.getString(R.string.feed_hashtag) -> HASHTAG_FEED
else -> HOME_FEED
}
}
fun fromName(name: String): Tab {
return entries.filter { it.name == name }.getOrElse(0) { HOME_FEED }
}
val defaultTabs: List<Tab>
get() = listOf(
HOME_FEED,
SEARCH_DISCOVER_FEED,
CREATE_FEED,
NOTIFICATIONS_FEED,
PUBLIC_FEED
)
val otherTabs: List<Tab>
get() = listOf(
DIRECT_MESSAGES,
HASHTAG_FEED
)
}
}
}

@ -138,12 +138,6 @@ interface PixelfedAPI {
@Field("reblogs") reblogs : Boolean = true
) : Relationship
@POST("/api/v1.1/direct/lookup")
suspend fun lookupUser(
@Query("q") q: String,
@Query("remote") remote : Boolean = false
) : List<LookupUser>
@POST("/api/v1/accounts/{id}/unfollow")
suspend fun unfollow(
@Path("id") statusId: String,
@ -338,37 +332,42 @@ interface PixelfedAPI {
@Query("account_id") account_id: Boolean? = null
): List<Notification>
@GET("/api/v1/conversations")
suspend fun viewAllConversations(
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: String? = null
): List<Conversation>
@DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation(
@Path("id") id: String
)
@POST("/api/v1/conversations/{id}/read")
suspend fun markConversationAsRead(
@Path("id") id: String
): Conversation
@GET("/api/v1/accounts/verify_credentials")
suspend fun verifyCredentials(
//The authorization header needs to be of the form "Bearer <token>"
@Header("Authorization") authorization: String? = null
): Account
//@Multipart
@PATCH("/api/v1/accounts/update_credentials")
suspend fun updateCredentials(
@Query(value = "display_name") displayName: String?,
@Query(value = "note") note: String?,
@Query(value = "locked") locked: Boolean?,
// @Part avatar: MultipartBody.Part?,
): Account
/**
* Pixelfed uses PHP, multipart uploads don't work through PATCH so we use POST as suggested
* here: https://github.com/pixelfed/pixelfed/issues/4250
* However, changing to POST breaks the upload on Mastodon.
*
* To have this work on Pixelfed and Mastodon without special logic to distinguish the two,
* we'll have to wait for PHP 8.4 and https://wiki.php.net/rfc/rfc1867-non-post
* which should come out end of 2024
*/
@Multipart
@POST("/api/v1/accounts/update_credentials")
fun updateProfilePicture(
@Part avatar: MultipartBody.Part?
): Observable<Account>
@Multipart
@PATCH("/api/v1/accounts/update_credentials")
fun updateProfilePictureMastodon(
fun updateProfilePicture(
@Part avatar: MultipartBody.Part?
): Observable<Account>
@ -435,30 +434,6 @@ interface PixelfedAPI {
@GET("/api/v1.1/discover/posts/hashtags")
suspend fun trendingHashtags() : List<Tag>
@GET("/api/v1/conversations")
suspend fun directMessagesList(
// @Query("max_id") max_id: String? = null,
// @Query("since_id") since_id: String? = null,
// @Query("min_id") min_id: String? = null,
@Query("page") page: Int? = null,
@Query("limit") limit: String? = null,
): List<Conversation>
@GET("/api/v1.1/direct/thread")
suspend fun directMessagesConversation(
@Query("pid") pid: String? = null,
@Query("max_id") max_id: String? = null,
@Query("min_id") min_id: String? = null,
): DMThread
@POST("/api/v1.1/direct/thread/send")
suspend fun sendDirectMessage(
@Query("to_id") to_id: String? = null,
@Query("message") message: String? = null,
// text or emoji
@Query("type") min_id: String = "text",
): DMThread
@FormUrlEncoded
@POST("/api/v1/reports")
@JvmSuppressWildcards
@ -469,11 +444,4 @@ interface PixelfedAPI {
@Field("forward") forward: Boolean = true
) : Report
@POST("/api/pixelfed/v1/app/settings")
suspend fun setSettings(
@Body account_id: CommonWrapper,
) : PixelfedAppSettings
@GET("/api/pixelfed/v1/app/settings")
suspend fun getSettings() : PixelfedAppSettings
}

@ -10,8 +10,9 @@ import java.io.Serializable
Represents a conversation.
https://docs.joinmastodon.org/entities/Conversation/
*/
@Entity(
tableName = "directMessages",
tableName = "direct_messages",
primaryKeys = ["id", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
@ -23,13 +24,18 @@ https://docs.joinmastodon.org/entities/Conversation/
indices = [Index(value = ["user_id", "instance_uri"])]
)
data class Conversation(
override val id: String,
val unread: Boolean?,
val accounts: List<Account>?,
val last_status: Status?,
//Base attributes
override val id: String?,
val unread: Boolean? = true,
val accounts: List<Account>? = null,
val last_status: Status? = null,
//Database values (not from API)
//TODO do we find this approach acceptable? Preferable to a semi-duplicate ConversationDataBaseEntity?
//TODO do we find this approach acceptable? Preferable to a semi-duplicate NotificationDataBaseEntity?
override var user_id: String,
override var instance_uri: String,
): FeedContent, FeedContentDatabase, Serializable
): FeedContent, FeedContentDatabase, Serializable {
enum class NotificationType : Serializable {
follow, follow_request, mention, reblog, favourite, poll, status, comment //comment is Pixelfed-specific?
}
}

@ -1,29 +0,0 @@
package org.pixeldroid.app.utils.api.objects
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import java.io.Serializable
import java.time.Instant
/*
Represents a conversation.
https://docs.joinmastodon.org/entities/Conversation/
*/
data class DMThread(
override val id: String,
val name: String?,
val username: String?,
val avatar: String?,
val url: String?,
val muted: Boolean?,
val isLocal: Boolean?,
val domain: String?,
val created_at: Instant?, //ISO 8601 Datetime
val updated_at: Instant?,
val timeAgo: String?,
val lastMessage: String?,
val messages: List<Message>,
): FeedContent, Serializable

@ -1,6 +0,0 @@
package org.pixeldroid.app.utils.api.objects
data class LookupUser(
val name: String,
val id: String
)

@ -1,19 +0,0 @@
package org.pixeldroid.app.utils.api.objects
import java.io.Serializable
import java.time.Instant
data class Message(
override val id: String,
val name: String?,
val hidden: Boolean?,
val isAuthor: Boolean?,
val type: String?, //TODO enum?
val text: String?,
val media: String?, //TODO,
val carousel: List<Attachment>?,
val created_at: Instant?, //ISO 8601 Datetime
val timeAgo: String?,
val reportId: String?,
//val meta: String?, //TODO
): FeedContent, Serializable

@ -1,34 +0,0 @@
package org.pixeldroid.app.utils.api.objects
data class PixelfedAppSettings(
val id: String,
val username: String,
val updated_at: String,
val common: Common
)
data class CommonWrapper(
val common: Common
)
data class Common(
val timelines: Timelines,
val media: Media,
val appearance: Appearance
)
data class Timelines(
val show_public: Boolean,
val show_network: Boolean,
val hide_likes_shares: Boolean
)
data class Media(
val hide_public_behind_cw: Boolean,
val always_show_cw: Boolean,
val show_alt_text: Boolean
)
data class Appearance(
val links_use_in_app_browser: Boolean,
val theme: String
)

@ -1,27 +1,21 @@
package org.pixeldroid.app.utils.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.dao.InstanceDao
import org.pixeldroid.app.utils.db.dao.TabsDao
import org.pixeldroid.app.utils.db.dao.UserDao
import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesConversationDao
import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesDao
import org.pixeldroid.app.utils.db.dao.*
import org.pixeldroid.app.utils.db.dao.feedContent.NotificationDao
import org.pixeldroid.app.utils.db.dao.feedContent.posts.HomePostDao
import org.pixeldroid.app.utils.db.dao.feedContent.posts.PublicPostDao
import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesDao
@Database(entities = [
InstanceDatabaseEntity::class,
@ -29,18 +23,10 @@ import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
HomeStatusDatabaseEntity::class,
PublicFeedStatusDatabaseEntity::class,
Notification::class,
TabsDatabaseEntity::class,
Conversation::class,
DirectMessageDatabaseEntity::class,
Conversation::class
],
autoMigrations = [
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
],
version = 10
version = 5
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun instanceDao(): InstanceDao
@ -48,9 +34,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun homePostDao(): HomePostDao
abstract fun publicPostDao(): PublicPostDao
abstract fun notificationDao(): NotificationDao
abstract fun tabsDao(): TabsDao
abstract fun directMessagesDao(): DirectMessagesDao
abstract fun directMessagesConversationDao(): DirectMessagesConversationDao
}
val MIGRATION_3_4 = object : Migration(3, 4) {
@ -64,16 +48,4 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE instances ADD COLUMN videoEnabled INTEGER NOT NULL DEFAULT 1")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE instances ADD COLUMN pixelfed INTEGER NOT NULL DEFAULT 1")
}
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Sorry users, this was just easier
database.execSQL("DELETE FROM tabsChecked")
}
}

@ -137,15 +137,6 @@ class Converters {
Status.Visibility::class.java
)
@TypeConverter
fun accountListToJson(type: List<Account>?): String {
val listType = object : TypeToken<List<Account?>?>() {}.type
return gson.toJson(type, listType)
}
@TypeConverter
fun jsonToAccountList(json: String): List<Account>? {
val listType = object : TypeToken<List<Account?>?>() {}.type
return gson.fromJson(json, listType)
}
}

@ -13,58 +13,41 @@ import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity.Companion.DEF
import org.pixeldroid.app.utils.normalizeDomain
import java.lang.IllegalArgumentException
suspend fun addUser(
db: AppDatabase, account: Account, instance_uri: String, activeUser: Boolean = true,
accessToken: String, refreshToken: String?, clientId: String, clientSecret: String,
) {
fun addUser(db: AppDatabase, account: Account, instance_uri: String, activeUser: Boolean = true,
accessToken: String, refreshToken: String?, clientId: String, clientSecret: String) {
db.userDao().insertOrUpdate(
UserDatabaseEntity(
user_id = account.id!!,
instance_uri = normalizeDomain(instance_uri),
username = account.username!!,
display_name = account.getDisplayName(),
avatar_static = account.anyAvatar().orEmpty(),
isActive = activeUser,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
UserDatabaseEntity(
user_id = account.id!!,
instance_uri = normalizeDomain(instance_uri),
username = account.username!!,
display_name = account.getDisplayName(),
avatar_static = account.anyAvatar().orEmpty(),
isActive = activeUser,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
)
}
suspend fun updateUserInfoDb(db: AppDatabase, account: Account) {
val user = db.userDao().getActiveUser()!!
db.userDao().updateUserAccountDetails(
account.username.orEmpty(),
account.display_name.orEmpty(),
account.anyAvatar().orEmpty(),
user.user_id,
user.instance_uri
)
}
suspend fun storeInstance(db: AppDatabase, nodeInfo: NodeInfo?, instance: Instance? = null) {
fun storeInstance(db: AppDatabase, nodeInfo: NodeInfo?, instance: Instance? = null) {
val dbInstance: InstanceDatabaseEntity = nodeInfo?.run {
InstanceDatabaseEntity(
uri = normalizeDomain(metadata?.config?.site?.url!!),
title = metadata.config.site.name!!,
maxStatusChars = metadata.config.uploader?.max_caption_length!!.toInt(),
maxPhotoSize = metadata.config.uploader.max_photo_size?.toIntOrNull()
?: DEFAULT_MAX_PHOTO_SIZE,
maxPhotoSize = metadata.config.uploader.max_photo_size?.toIntOrNull() ?: DEFAULT_MAX_PHOTO_SIZE,
// Pixelfed doesn't distinguish between max photo and video size
maxVideoSize = metadata.config.uploader.max_photo_size?.toIntOrNull()
?: DEFAULT_MAX_VIDEO_SIZE,
maxVideoSize = metadata.config.uploader.max_photo_size?.toIntOrNull() ?: DEFAULT_MAX_VIDEO_SIZE,
albumLimit = metadata.config.uploader.album_limit?.toIntOrNull() ?: DEFAULT_ALBUM_LIMIT,
videoEnabled = metadata.config.features?.video ?: DEFAULT_VIDEO_ENABLED,
pixelfed = metadata.software?.repo?.contains("pixelfed", ignoreCase = true) == true
videoEnabled = metadata.config.features?.video ?: DEFAULT_VIDEO_ENABLED
)
} ?: instance?.run {
InstanceDatabaseEntity(
uri = normalizeDomain(uri.orEmpty()),
title = title.orEmpty(),
maxStatusChars = max_toot_chars?.toInt() ?: DEFAULT_MAX_TOOT_CHARS,
pixelfed = false
uri = normalizeDomain(uri.orEmpty()),
title = title.orEmpty(),
maxStatusChars = max_toot_chars?.toInt() ?: DEFAULT_MAX_TOOT_CHARS,
)
} ?: throw IllegalArgumentException("Cannot store instance where both are null")

@ -1,33 +1,27 @@
package org.pixeldroid.app.utils.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.*
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
@Dao
interface InstanceDao {
@Query("SELECT * FROM instances")
fun getAll(): List<InstanceDatabaseEntity>
@Query("SELECT * FROM instances WHERE uri=:instanceUri")
fun getInstance(instanceUri: String): InstanceDatabaseEntity
@Query("SELECT * FROM instances WHERE uri=(SELECT users.instance_uri FROM users WHERE isActive=1)")
fun getActiveInstance(): InstanceDatabaseEntity
/**
* Insert an instance, if it already exists return -1
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertInstance(instance: InstanceDatabaseEntity): Long
fun insertInstance(instance: InstanceDatabaseEntity): Long
@Update
suspend fun updateInstance(instance: InstanceDatabaseEntity)
fun updateInstance(instance: InstanceDatabaseEntity)
@Transaction
suspend fun insertOrUpdate(instance: InstanceDatabaseEntity) {
fun insertOrUpdate(instance: InstanceDatabaseEntity) {
if (insertInstance(instance) == -1L) {
updateInstance(instance)
}

@ -1,36 +0,0 @@
package org.pixeldroid.app.utils.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
@Dao
interface TabsDao {
@Query("SELECT * FROM tabsChecked WHERE `index`=:index AND `user_id`=:userId AND `instance_uri`=:instanceUri")
fun getTabChecked(index: Int, userId: String, instanceUri: String): TabsDatabaseEntity?
@Query("SELECT * FROM tabsChecked WHERE `user_id`=:userId AND `instance_uri`=:instanceUri")
fun getTabsChecked(userId: String, instanceUri: String): List<TabsDatabaseEntity>
@Query("DELETE FROM tabsChecked WHERE `index`=:index AND `user_id`=:userId AND `instance_uri`=:instanceUri")
fun deleteTabChecked(index: Int, userId: String, instanceUri: String)
@Query("DELETE FROM tabsChecked WHERE `user_id`=:userId AND `instance_uri`=:instanceUri")
fun deleteTabsChecked(userId: String, instanceUri: String)
/**
* Insert a tab, if it already exists return -1
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTabChecked(tabChecked: TabsDatabaseEntity): Long
@Transaction
suspend fun clearAndRefill(tabsChecked: List<TabsDatabaseEntity>, userId: String, instanceUri: String) {
deleteTabsChecked(userId, instanceUri)
tabsChecked.forEach { insertTabChecked(it) }
}
}

@ -1,12 +1,6 @@
package org.pixeldroid.app.utils.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import androidx.room.*
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
@Dao
@ -15,21 +9,17 @@ interface UserDao {
* Insert a user, if it already exists return -1
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertUser(user: UserDatabaseEntity): Long
fun insertUser(user: UserDatabaseEntity): Long
@Transaction
suspend fun insertOrUpdate(user: UserDatabaseEntity) {
fun insertOrUpdate(user: UserDatabaseEntity) {
if (insertUser(user) == -1L) {
updateUser(user)
}
}
@Update
suspend fun updateUser(user: UserDatabaseEntity)
@Query("UPDATE users SET username = :username, display_name = :displayName, avatar_static = :avatarStatic WHERE user_id = :id and instance_uri = :instanceUri")
suspend fun updateUserAccountDetails(username: String, displayName: String, avatarStatic: String, id: String, instanceUri: String)
fun updateUser(user: UserDatabaseEntity)
@Query("UPDATE users SET accessToken = :accessToken, refreshToken = :refreshToken WHERE user_id = :id and instance_uri = :instanceUri")
fun updateAccessToken(accessToken: String, refreshToken: String, id: String, instanceUri: String)
@ -37,9 +27,6 @@ interface UserDao {
@Query("SELECT * FROM users")
fun getAll(): List<UserDatabaseEntity>
@Query("SELECT * FROM users")
fun getAllFlow(): Flow<List<UserDatabaseEntity>>
@Query("SELECT * FROM users WHERE isActive=1")
fun getActiveUser(): UserDatabaseEntity?

@ -1,25 +0,0 @@
package org.pixeldroid.app.utils.db.dao.feedContent
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
@Dao
interface DirectMessagesConversationDao: FeedContentDao<DirectMessageDatabaseEntity> {
@Query("DELETE FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId")
override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
//TODO think about ordering
@Query("SELECT * FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId ORDER BY datetime(created_at) DESC")
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, DirectMessageDatabaseEntity>
@Query("DELETE FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND conversationsId=:conversationsId")
override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
@Query("SELECT id FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId ORDER BY datetime(created_at) ASC LIMIT 1")
suspend fun lastMessageId(userId: String, instanceUri: String, conversationsId: String): String?
}

@ -4,17 +4,16 @@ import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import org.pixeldroid.app.utils.api.objects.Conversation
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
@Dao
interface DirectMessagesDao: FeedContentDao<Conversation> {
@Query("DELETE FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM direct_messages WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
//TODO think about ordering
@Query("""SELECT * FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""""")
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, Conversation>
@Query("DELETE FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
// TODO: might have to order by date or some other value
@Query("""SELECT * FROM direct_messages WHERE user_id=:userId AND instance_uri=:instanceUri """)
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, Conversation>
}

@ -7,14 +7,10 @@ import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
interface FeedContentDao<T: FeedContentDatabase>{
fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, T>
fun feedContent(userId: String, instanceUri: String): PagingSource<Int, T>
suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
suspend fun clearFeedContent(userId: String, instanceUri: String) = clearFeedContent(userId, instanceUri, "")
suspend fun clearFeedContent(userId: String, instanceUri: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedContent: List<T>)
suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
suspend fun delete(id: String, userId: String, instanceUri: String) = delete(id, userId, instanceUri, "")
}

@ -8,17 +8,14 @@ import org.pixeldroid.app.utils.api.objects.Notification
@Dao
interface NotificationDao: FeedContentDao<Notification> {
@Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, Notification>
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, Notification>
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC LIMIT 1""")
fun latestNotification(userId: String, instanceUri: String): Notification?
@Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
}

@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
@Dao
interface HomePostDao: FeedContentDao<HomeStatusDatabaseEntity> {
@Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
@Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, HomeStatusDatabaseEntity>
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, HomeStatusDatabaseEntity>
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
suspend fun delete(id: String, userId: String, instanceUri: String)
@Query("UPDATE homePosts SET bookmarked=:bookmarked WHERE user_id=:id AND instance_uri=:instanceUri AND id=:statusId")
fun bookmarkStatus(id: String, instanceUri: String, statusId: String, bookmarked: Boolean)

@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
@Dao
interface PublicPostDao: FeedContentDao<PublicFeedStatusDatabaseEntity> {
@Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
@Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC""")
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, PublicFeedStatusDatabaseEntity>
override fun feedContent(userId: String, instanceUri: String): PagingSource<Int, PublicFeedStatusDatabaseEntity>
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri")
override suspend fun clearFeedContent(userId: String, instanceUri: String)
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
@Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
override suspend fun delete(id: String, userId: String, instanceUri: String)
@Query("UPDATE homePosts SET bookmarked=:bookmarked WHERE user_id=:id AND instance_uri=:instanceUri AND id=:statusId")
fun bookmarkStatus(id: String, instanceUri: String, statusId: String, bookmarked: Boolean)

@ -1,62 +0,0 @@
package org.pixeldroid.app.utils.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
import org.pixeldroid.app.utils.api.objects.Message
import java.io.Serializable
import java.time.Instant
@Entity(
tableName = "directMessagesThreads",
primaryKeys = ["id", "conversationsId", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id", "instance_uri", "conversationsId"])]
)
data class DirectMessageDatabaseEntity(
override val id: String,
val name: String?,
val hidden: Boolean?,
val isAuthor: Boolean?,
val type: String?, //TODO enum?
val text: String?,
val media: String?, //TODO,
val carousel: List<Attachment>?,
val created_at: Instant?, //ISO 8601 Datetime
val timeAgo: String?,
val reportId: String?,
//val meta: String?, //TODO
// Database values (not from API)
val conversationsId: String,
override var user_id: String,
override var instance_uri: String,
): FeedContent, FeedContentDatabase, Serializable {
constructor(message: Message, conversationsId: String, user: UserDatabaseEntity) : this(
message.id,
message.name,
message.hidden,
message.isAuthor,
message.type,
message.text,
message.media,
message.carousel,
message.created_at,
message.timeAgo,
message.reportId,
//message.meta,
conversationsId,
user.user_id,
user.instance_uri
)
}

@ -4,22 +4,20 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "instances")
data class InstanceDatabaseEntity(
@PrimaryKey var uri: String,
var title: String,
var maxStatusChars: Int = DEFAULT_MAX_TOOT_CHARS,
// Per-file file-size limit in KB. Defaults to 15000 (15MB). Default limit for Mastodon is 8MB
var maxPhotoSize: Int = DEFAULT_MAX_PHOTO_SIZE,
// Mastodon has different file limits for videos, default of 40MB
var maxVideoSize: Int = DEFAULT_MAX_VIDEO_SIZE,
// How many photos can go into an album. Default limit for Pixelfed and Mastodon is 4
var albumLimit: Int = DEFAULT_ALBUM_LIMIT,
// Is video functionality enabled on this instance?
var videoEnabled: Boolean = DEFAULT_VIDEO_ENABLED,
// Is this Pixelfed instance?
var pixelfed: Boolean = true,
data class InstanceDatabaseEntity (
@PrimaryKey var uri: String,
var title: String,
var maxStatusChars: Int = DEFAULT_MAX_TOOT_CHARS,
// Per-file file-size limit in KB. Defaults to 15000 (15MB). Default limit for Mastodon is 8MB
var maxPhotoSize: Int = DEFAULT_MAX_PHOTO_SIZE,
// Mastodon has different file limits for videos, default of 40MB
var maxVideoSize: Int = DEFAULT_MAX_VIDEO_SIZE,
// How many photos can go into an album. Default limit for Pixelfed and Mastodon is 4
var albumLimit: Int = DEFAULT_ALBUM_LIMIT,
// Is video functionality enabled on this instance?
var videoEnabled: Boolean = DEFAULT_VIDEO_ENABLED,
) {
companion object {
companion object{
// Default max number of chars for Mastodon: used when their is no other value supplied by
// either NodeInfo or the instance endpoint
const val DEFAULT_MAX_TOOT_CHARS = 500

@ -1,26 +0,0 @@
package org.pixeldroid.app.utils.db.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "tabsChecked",
primaryKeys = ["index", "user_id", "instance_uri"],
foreignKeys = [ForeignKey(
entity = UserDatabaseEntity::class,
parentColumns = arrayOf("user_id", "instance_uri"),
childColumns = arrayOf("user_id", "instance_uri"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["user_id", "instance_uri"])]
)
data class TabsDatabaseEntity(
var index: Int,
var user_id: String,
var instance_uri: String,
var tab: String,
var checked: Boolean = true,
var filter: String? = null
)

@ -6,16 +6,13 @@ import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import okhttp3.*
import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class APIModule {
class APIModule{
@Provides
@Singleton
@ -57,7 +54,7 @@ class TokenAuthenticator(val user: UserDatabaseEntity, val db: AppDatabase, val
client_secret = user.clientSecret
)
}
} catch (e: Exception){
}catch (e: Exception){
return null
}

@ -0,0 +1,34 @@
package org.pixeldroid.app.utils.di
import android.app.Application
import android.content.Context
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.BaseFragment
import dagger.Component
import org.pixeldroid.app.directMessages.ui.main.ConversationsViewModel
import org.pixeldroid.app.postCreation.PostCreationViewModel
import org.pixeldroid.app.profile.EditProfileViewModel
import org.pixeldroid.app.stories.StoriesViewModel
import org.pixeldroid.app.stories.StoryCarouselViewHolder
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import javax.inject.Singleton
@Singleton
@Component(modules = [ApplicationModule::class, DatabaseModule::class, APIModule::class])
interface ApplicationComponent {
fun inject(application: PixelDroidApplication?)
fun inject(activity: BaseActivity?)
fun inject(feedFragment: BaseFragment)
fun inject(notificationsWorker: NotificationsWorker)
fun inject(postCreationViewModel: PostCreationViewModel)
fun inject(editProfileViewModel: EditProfileViewModel)
fun inject(editProfileViewModel: ConversationsViewModel)
fun inject(storiesViewModel: StoriesViewModel)
val context: Context?
val application: Application?
val database: AppDatabase
}

@ -0,0 +1,27 @@
package org.pixeldroid.app.utils.di
import android.app.Application
import android.content.Context
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@Module
class ApplicationModule(app: Application) {
private val mApplication: Application = app
@Singleton
@Provides
fun provideContext(): Context {
return mApplication
}
@Singleton
@Provides
fun provideApplication(): Application {
return mApplication
}
}

@ -5,28 +5,20 @@ import androidx.room.Room
import org.pixeldroid.app.utils.db.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.pixeldroid.app.utils.db.MIGRATION_3_4
import org.pixeldroid.app.utils.db.MIGRATION_4_5
import org.pixeldroid.app.utils.db.MIGRATION_5_6
import org.pixeldroid.app.utils.db.MIGRATION_9_10
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {
class DatabaseModule(private val context: Context) {
@Provides
@Singleton
fun providesDatabase(
@ApplicationContext applicationContext: Context
): AppDatabase {
fun providesDatabase(): AppDatabase {
return Room.databaseBuilder(
applicationContext,
context,
AppDatabase::class.java, "pixeldroid"
).addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_9_10)
).addMigrations(MIGRATION_3_4).addMigrations(MIGRATION_4_5)
.allowMainThreadQueries().build()
}
}

@ -12,42 +12,43 @@ import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.PostActivity
import org.pixeldroid.app.posts.fromHtml
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.comment
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.entries
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.favourite
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.follow
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.follow_request
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.mention
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.poll
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.reblog
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.status
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.*
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.getColorFromAttr
import retrofit2.HttpException
import java.io.IOException
import java.time.Instant
import javax.inject.Inject
@HiltWorker
class NotificationsWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val db: AppDatabase,
private val apiHolder: PixelfedAPIHolder
class NotificationsWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override suspend fun doWork(): Result {
(applicationContext as PixelDroidApplication).getAppComponent().inject(this)
val users: List<UserDatabaseEntity> = db.userDao().getAll()
for (user in users){
@ -305,7 +306,8 @@ fun removeNotificationChannelsFromAccount(context: Context, user: UserDatabaseEn
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
notificationManager.deleteNotificationChannelGroup(channelGroupId.hashCode().toString())
} else {
val types: MutableList<Notification.NotificationType?> = entries.toMutableList()
val types: MutableList<Notification.NotificationType?> =
Notification.NotificationType.values().toMutableList()
types += null
types.forEach {

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20,10L20,8h-4L16,4h-2v4h-4L10,4L8,4v4L4,8v2h4v4L4,14v2h4v4h2v-4h4v4h2v-4h4v-2h-4v-4h4zM14,14h-4v-4h4v4z"/>
</vector>

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="?attr/colorOnBackground" android:pathData="M19,2L5,2c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h4l3,3 3,-3h4c1.1,0 2,-0.9 2,-2L21,4c0,-1.1 -0.9,-2 -2,-2zM13,18h-2v-2h2v2zM15.07,10.25l-0.9,0.92C13.45,11.9 13,12.5 13,14h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,8c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
</vector>

@ -1,5 +1,5 @@
<vector android:height="24dp"
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/colorOnBackground" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
<path android:fillColor="#FF000000" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="?attr/colorOnBackground" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>

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