Merge branch 'use_server_settings' into 'master'

Use server settings

Closes #258

See merge request pixeldroid/PixelDroid!302
This commit is contained in:
Matthieu 2021-02-07 22:01:02 +00:00
commit a9a4ae10bf
18 changed files with 338 additions and 196 deletions

View File

@ -74,13 +74,13 @@ dependencies {
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2' implementation 'androidx.navigation:navigation-ui-ktx:2.3.3'
implementation "androidx.browser:browser:1.3.0" implementation "androidx.browser:browser:1.3.0"
implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2' implementation 'androidx.navigation:navigation-ui-ktx:2.3.3'
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha12' implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha12'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0'
@ -90,16 +90,16 @@ dependencies {
implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.gridlayout:gridlayout:1.0.0'
// Use the most recent version of CameraX // Use the most recent version of CameraX
def cameraX_version = '1.0.0-rc01' def cameraX_version = '1.0.0-rc02'
implementation "androidx.camera:camera-core:${cameraX_version}" implementation "androidx.camera:camera-core:${cameraX_version}"
implementation "androidx.camera:camera-camera2:${cameraX_version}" implementation "androidx.camera:camera-camera2:${cameraX_version}"
// CameraX Lifecycle library // CameraX Lifecycle library
implementation "androidx.camera:camera-lifecycle:$cameraX_version" implementation "androidx.camera:camera-lifecycle:$cameraX_version"
// CameraX View class // CameraX View class
implementation 'androidx.camera:camera-view:1.0.0-alpha20' implementation 'androidx.camera:camera-view:1.0.0-alpha21'
def room_version = "2.3.0-alpha04" def room_version = "2.3.0-beta01"
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version" kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-ktx:$room_version"
@ -125,7 +125,7 @@ dependencies {
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0' implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'io.reactivex.rxjava2:rxjava:2.2.20' implementation 'io.reactivex.rxjava2:rxjava:2.2.20'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'com.github.connyduck:sparkbutton:4.0.0' implementation 'com.github.connyduck:sparkbutton:4.1.0'
implementation 'info.androidhive:imagefilters:1.0.7' implementation 'info.androidhive:imagefilters:1.0.7'

View File

@ -10,22 +10,15 @@ import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.h.pixeldroid.databinding.ActivityLoginBinding import com.h.pixeldroid.databinding.ActivityLoginBinding
import com.h.pixeldroid.databinding.ActivityPostCreationBinding import com.h.pixeldroid.utils.*
import com.h.pixeldroid.utils.BaseActivity
import com.h.pixeldroid.utils.api.PixelfedAPI import com.h.pixeldroid.utils.api.PixelfedAPI
import com.h.pixeldroid.utils.api.objects.* import com.h.pixeldroid.utils.api.objects.*
import com.h.pixeldroid.utils.db.addUser import com.h.pixeldroid.utils.db.addUser
import com.h.pixeldroid.utils.db.storeInstance import com.h.pixeldroid.utils.db.storeInstance
import com.h.pixeldroid.utils.hasInternet import kotlinx.coroutines.*
import com.h.pixeldroid.utils.normalizeDomain
import com.h.pixeldroid.utils.openUrl
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import okhttp3.HttpUrl
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
import java.lang.IllegalArgumentException
/** /**
Overview of the flow of the login process: (boxes are requests done in parallel, Overview of the flow of the login process: (boxes are requests done in parallel,
@ -125,11 +118,7 @@ class LoginActivity : BaseActivity() {
private fun registerAppToServer(normalizedDomain: String) { private fun registerAppToServer(normalizedDomain: String) {
try{ if(!validDomain(normalizedDomain)) failedRegistration(getString(R.string.invalid_domain))
HttpUrl.Builder().host(normalizedDomain.replace("https://", "")).scheme("https").build()
} catch (e: IllegalArgumentException) {
return failedRegistration(getString(R.string.invalid_domain))
}
hideKeyboard() hideKeyboard()
loadingAnimation(true) loadingAnimation(true)
@ -138,7 +127,6 @@ class LoginActivity : BaseActivity() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
supervisorScope { }
val credentialsDeferred: Deferred<Application?> = async { val credentialsDeferred: Deferred<Application?> = async {
try { try {
pixelfedAPI.registerApplication( pixelfedAPI.registerApplication(
@ -157,7 +145,6 @@ class LoginActivity : BaseActivity() {
val clientId = credentials?.client_id ?: return@launch failedRegistration() val clientId = credentials?.client_id ?: return@launch failedRegistration()
preferences.edit() preferences.edit()
.putString("domain", normalizedDomain)
.putString("clientID", clientId) .putString("clientID", clientId)
.putString("clientSecret", credentials.client_secret) .putString("clientSecret", credentials.client_secret)
.apply() .apply()
@ -181,18 +168,39 @@ class LoginActivity : BaseActivity() {
normalizedDomain: String, normalizedDomain: String,
clientId: String, clientId: String,
nodeInfoSchemaUrl: String nodeInfoSchemaUrl: String
) { ) = coroutineScope {
val nodeInfo = try {
val nodeInfo: NodeInfo = try {
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl) pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
} catch (exception: IOException) { } catch (exception: IOException) {
return failedRegistration(getString(R.string.instance_error)) return@coroutineScope failedRegistration(getString(R.string.instance_error))
} catch (exception: HttpException) { } catch (exception: HttpException) {
return failedRegistration(getString(R.string.instance_error)) return@coroutineScope failedRegistration(getString(R.string.instance_error))
} }
val domain: String = try {
if (nodeInfo.hasInstanceEndpointInfo()) {
storeInstance(db, nodeInfo)
nodeInfo.metadata?.config?.site?.url
} else {
val instance: Instance = try {
pixelfedAPI.instance()
} catch (exception: IOException) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
} catch (exception: HttpException) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
}
storeInstance(db, nodeInfo = null, instance = instance)
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")) { if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
val builder = AlertDialog.Builder(this@LoginActivity) AlertDialog.Builder(this@LoginActivity).apply {
builder.apply {
setMessage(R.string.instance_not_pixelfed_warning) setMessage(R.string.instance_not_pixelfed_warning)
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ -> setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
promptOAuth(normalizedDomain, clientId) promptOAuth(normalizedDomain, clientId)
@ -201,13 +209,18 @@ class LoginActivity : BaseActivity() {
loadingAnimation(false) loadingAnimation(false)
wipeSharedSettings() wipeSharedSettings()
} }
} }.show()
// Create the AlertDialog } else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
builder.show() AlertDialog.Builder(this@LoginActivity).apply {
setMessage(R.string.api_not_enabled_dialog)
setNegativeButton(android.R.string.ok) { _, _ ->
loadingAnimation(false)
wipeSharedSettings()
}
}.show()
} else { } else {
promptOAuth(normalizedDomain, clientId) promptOAuth(normalizedDomain, clientId)
} }
} }
@ -238,9 +251,6 @@ class LoginActivity : BaseActivity() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val instanceDeferred = async {
pixelfedAPI.instance()
}
val token = pixelfedAPI.obtainToken( val token = pixelfedAPI.obtainToken(
clientId, clientSecret, "$oauthScheme://$PACKAGE_ID", SCOPE, code, clientId, clientSecret, "$oauthScheme://$PACKAGE_ID", SCOPE, code,
"authorization_code" "authorization_code"
@ -248,20 +258,12 @@ class LoginActivity : BaseActivity() {
if (token.access_token == null) { if (token.access_token == null) {
return@launch failedRegistration(getString(R.string.token_error)) return@launch failedRegistration(getString(R.string.token_error))
} }
val instance = instanceDeferred.await()
if (instance.uri == null) {
return@launch failedRegistration(getString(R.string.instance_error))
}
storeInstance(db, instance)
storeUser( storeUser(
token.access_token, token.access_token,
token.refresh_token, token.refresh_token,
clientId, clientId,
clientSecret, clientSecret,
instance.uri domain
) )
wipeSharedSettings() wipeSharedSettings()
} catch (exception: IOException) { } catch (exception: IOException) {

View File

@ -61,15 +61,16 @@ class MainActivity : BaseActivity() {
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
TraceDroidEmailSender.sendStackTraces("contact@pixeldroid.org", this)
//get the currently active user //get the currently active user
user = db.userDao().getActiveUser() user = db.userDao().getActiveUser()
//Check if we have logged in and gotten an access token //Check if we have logged in and gotten an access token
if (user == null) { if (user == null) {
launchActivity(LoginActivity(), firstTime = true) launchActivity(LoginActivity(), firstTime = true)
finish()
} else { } else {
TraceDroidEmailSender.sendStackTraces("contact@pixeldroid.org", this)
setupDrawer() setupDrawer()
val tabs: List<() -> Fragment> = listOf( val tabs: List<() -> Fragment> = listOf(

View File

@ -1,10 +1,8 @@
package com.h.pixeldroid.postCreation package com.h.pixeldroid.postCreation
import android.app.Activity import android.app.Activity
import android.content.ContentResolver import android.app.AlertDialog
import android.content.ContentValues import android.content.*
import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -31,7 +29,7 @@ import com.h.pixeldroid.postCreation.photoEdit.PhotoEditActivity
import com.h.pixeldroid.utils.BaseActivity import com.h.pixeldroid.utils.BaseActivity
import com.h.pixeldroid.utils.api.PixelfedAPI import com.h.pixeldroid.utils.api.PixelfedAPI
import com.h.pixeldroid.utils.api.objects.Attachment import com.h.pixeldroid.utils.api.objects.Attachment
import com.h.pixeldroid.utils.api.objects.Instance import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.utils.db.entities.UserDatabaseEntity import com.h.pixeldroid.utils.db.entities.UserDatabaseEntity
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
@ -44,15 +42,18 @@ import java.io.OutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.math.ceil
import kotlin.properties.Delegates
private const val TAG = "Post Creation Activity" private const val TAG = "Post Creation Activity"
private const val MORE_PICTURES_REQUEST_CODE = 0xffff private const val MORE_PICTURES_REQUEST_CODE = 0xffff
data class PhotoData( data class PhotoData(
var imageUri: Uri, var imageUri: Uri,
var size: Long,
var uploadId: String? = null, var uploadId: String? = null,
var progress: Int? = null, var progress: Int? = null,
var imageDescription: String? = null var imageDescription: String? = null,
) )
class PostCreationActivity : BaseActivity() { class PostCreationActivity : BaseActivity() {
@ -62,6 +63,7 @@ class PostCreationActivity : BaseActivity() {
private var positionResult = 0 private var positionResult = 0
private var user: UserDatabaseEntity? = null private var user: UserDatabaseEntity? = null
private lateinit var instance: InstanceDatabaseEntity
private val photoData: ArrayList<PhotoData> = ArrayList() private val photoData: ArrayList<PhotoData> = ArrayList()
@ -72,29 +74,18 @@ class PostCreationActivity : BaseActivity() {
binding = ActivityPostCreationBinding.inflate(layoutInflater) binding = ActivityPostCreationBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// get image URIs
if(intent.clipData != null) {
val count = intent.clipData!!.itemCount
for (i in 0 until count) {
intent.clipData!!.getItemAt(i).uri.let {
photoData.add(PhotoData(it))
}
}
}
user = db.userDao().getActiveUser() user = db.userDao().getActiveUser()
val instances = db.instanceDao().getAll() instance = user?.run {
db.instanceDao().getAll().first { instanceDatabaseEntity ->
instanceDatabaseEntity.uri.contains(instance_uri)
}
} ?: InstanceDatabaseEntity("", "")
binding.postTextInputLayout.counterMaxLength = if (user != null){ binding.postTextInputLayout.counterMaxLength = instance.maxStatusChars
val thisInstances =
instances.filter { instanceDatabaseEntity -> // get image URIs
instanceDatabaseEntity.uri.contains(user!!.instance_uri) intent.clipData?.let { addPossibleImages(it) }
}
thisInstances.first().max_toot_chars
} else {
Instance.DEFAULT_MAX_TOOT_CHARS
}
accessToken = user?.accessToken.orEmpty() accessToken = user?.accessToken.orEmpty()
pixelfedAPI = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db) pixelfedAPI = apiHolder.api ?: apiHolder.setDomainToCurrentUser(db)
@ -102,15 +93,15 @@ class PostCreationActivity : BaseActivity() {
val carousel: ImageCarousel = binding.carousel val carousel: ImageCarousel = binding.carousel
carousel.addData(photoData.map { CarouselItem(it.imageUri) }) carousel.addData(photoData.map { CarouselItem(it.imageUri) })
carousel.layoutCarouselCallback = { carousel.layoutCarouselCallback = {
//TODO transition instead of at once
if(it){ if(it){
// Became a carousel // Became a carousel
binding.toolbar3.visibility = VISIBLE binding.toolbarPostCreation.visibility = VISIBLE
} else { } else {
// Became a grid // Became a grid
binding.toolbar3.visibility = INVISIBLE binding.toolbarPostCreation.visibility = INVISIBLE
} }
} }
carousel.maxEntries = instance.albumLimit
carousel.addPhotoButtonCallback = { carousel.addPhotoButtonCallback = {
addPhoto(applicationContext) addPhoto(applicationContext)
} }
@ -154,6 +145,63 @@ class PostCreationActivity : BaseActivity() {
carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition -> carousel.currentPosition.takeIf { it != -1 }?.let { currentPosition ->
photoData.removeAt(currentPosition) photoData.removeAt(currentPosition)
carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) }) carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) })
binding.addPhotoButton.isEnabled = true
}
}
}
/**
* Will add as many images as possible to [photoData], from the [clipData], and if
* ([photoData].size + [clipData].itemCount) > [albumLimit] 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.
*/
private fun addPossibleImages(clipData: ClipData){
var count = clipData.itemCount
if(count + photoData.size > instance.albumLimit){
AlertDialog.Builder(this).apply {
setMessage(getString(R.string.total_exceeds_album_limit).format(instance.albumLimit))
setNegativeButton(android.R.string.ok) { _, _ -> }
}.show()
count = count.coerceAtMost(instance.albumLimit - photoData.size)
}
if (count + photoData.size >= instance.albumLimit) {
// Disable buttons to add more images
binding.addPhotoButton.isEnabled = false
}
for (i in 0 until count) {
clipData.getItemAt(i).uri.let {
val size: Long =
if (it.toString().startsWith("content")) {
contentResolver.query(it, 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,
* and display it.
*/
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
} ?: 0
} else {
it.toFile().length()
}
val sizeInkBytes = ceil(size.toDouble() / 1000).toLong()
if(sizeInkBytes > instance.maxPhotoSize || sizeInkBytes > instance.maxVideoSize){
val maxSize = when {
instance.maxPhotoSize != instance.maxVideoSize -> {
val type = contentResolver.getType(it)
if(type?.startsWith("video/") == true){
instance.maxVideoSize
} else instance.maxPhotoSize
}
else -> instance.maxPhotoSize
}
AlertDialog.Builder(this).apply {
setMessage(getString(R.string.size_exceeds_instance_limit).format(photoData.size + 1, sizeInkBytes, maxSize))
setNegativeButton(android.R.string.ok) { _, _ -> }
}.show()
}
photoData.add(PhotoData(imageUri = it, size = size))
} }
} }
} }
@ -178,21 +226,21 @@ class PostCreationActivity : BaseActivity() {
if(path.startsWith("file")) { if(path.startsWith("file")) {
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(
this, this,
arrayOf(path.toUri().toFile().absolutePath), arrayOf(path.toUri().toFile().absolutePath),
null null
) { path, uri -> ) { path, uri ->
if (uri == null) { if (uri == null) {
Log.e( Log.e(
"NEW IMAGE SCAN FAILED", "NEW IMAGE SCAN FAILED",
"Tried to scan $path, but it failed" "Tried to scan $path, but it failed"
) )
} }
} }
} }
Snackbar.make( Snackbar.make(
button, getString(R.string.save_image_success), button, getString(R.string.save_image_success),
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
).show() ).show()
} }
@ -205,8 +253,8 @@ class PostCreationActivity : BaseActivity() {
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name) contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/png") contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
contentValues.put( contentValues.put(
MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES Environment.DIRECTORY_PICTURES
) )
val imageUri: Uri = val imageUri: Uri =
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!! resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!!
@ -253,23 +301,7 @@ class PostCreationActivity : BaseActivity() {
val imageUri = data.imageUri val imageUri = data.imageUri
val imageInputStream = contentResolver.openInputStream(imageUri)!! val imageInputStream = contentResolver.openInputStream(imageUri)!!
val size = val imagePart = ProgressRequestBody(imageInputStream, data.size)
if (imageUri.toString().startsWith("content")) {
contentResolver.query(imageUri, 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,
* and display it.
*/
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
} ?: 0
} else {
imageUri.toFile().length()
}
val imagePart = ProgressRequestBody(imageInputStream, size)
val requestBody = MultipartBody.Builder() val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart) .addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
@ -294,26 +326,33 @@ class PostCreationActivity : BaseActivity() {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ attachment: Attachment -> { attachment: Attachment ->
data.progress = 0 data.progress = 0
data.uploadId = attachment.id!! data.uploadId = attachment.id!!
}, },
{ e -> { e: Throwable ->
binding.uploadError.visibility = View.VISIBLE binding.uploadError.visibility = View.VISIBLE
e.printStackTrace() if(e is HttpException){
postSub?.dispose() binding.uploadErrorTextExplanation.text =
sub.dispose() getString(R.string.upload_error).format(e.code())
}, binding.uploadErrorTextExplanation.visibility= VISIBLE
{ } else {
data.progress = 100 binding.uploadErrorTextExplanation.visibility= View.GONE
if(photoData.all{it.progress == 100 && it.uploadId != null}){ }
binding.uploadProgressBar.visibility = View.GONE e.printStackTrace()
binding.uploadCompletedTextview.visibility = View.VISIBLE postSub?.dispose()
post() sub.dispose()
},
{
data.progress = 100
if (photoData.all { it.progress == 100 && it.uploadId != null }) {
binding.uploadProgressBar.visibility = View.GONE
binding.uploadCompletedTextview.visibility = View.VISIBLE
post()
}
postSub?.dispose()
sub.dispose()
} }
postSub?.dispose()
sub.dispose()
}
) )
} }
} }
@ -324,23 +363,23 @@ class PostCreationActivity : BaseActivity() {
lifecycleScope.launchWhenCreated { lifecycleScope.launchWhenCreated {
try { try {
pixelfedAPI.postStatus( pixelfedAPI.postStatus(
authorization = "Bearer $accessToken", authorization = "Bearer $accessToken",
statusText = description, statusText = description,
media_ids = photoData.mapNotNull { it.uploadId }.toList() media_ids = photoData.mapNotNull { it.uploadId }.toList()
) )
Toast.makeText(applicationContext,getString(R.string.upload_post_success), Toast.makeText(applicationContext, getString(R.string.upload_post_success),
Toast.LENGTH_SHORT).show() Toast.LENGTH_SHORT).show()
val intent = Intent(this@PostCreationActivity, MainActivity::class.java) val intent = Intent(this@PostCreationActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent) startActivity(intent)
} catch (exception: IOException) { } catch (exception: IOException) {
Toast.makeText(applicationContext,getString(R.string.upload_post_error), Toast.makeText(applicationContext, getString(R.string.upload_post_error),
Toast.LENGTH_SHORT).show() Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.toString()) Log.e(TAG, exception.toString())
enableButton(true) enableButton(true)
} catch (exception: HttpException) { } catch (exception: HttpException) {
Toast.makeText(applicationContext,getString(R.string.upload_post_failed), Toast.makeText(applicationContext, getString(R.string.upload_post_failed),
Toast.LENGTH_SHORT).show() Toast.LENGTH_SHORT).show()
Log.e(TAG, exception.response().toString() + exception.message().toString()) Log.e(TAG, exception.response().toString() + exception.message().toString())
enableButton(true) enableButton(true)
} }
@ -382,12 +421,10 @@ class PostCreationActivity : BaseActivity() {
Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show() Toast.makeText(applicationContext, "Error while editing", Toast.LENGTH_SHORT).show()
} }
} else if (requestCode == MORE_PICTURES_REQUEST_CODE) { } else if (requestCode == MORE_PICTURES_REQUEST_CODE) {
if (resultCode == Activity.RESULT_OK && data?.clipData != null) {
val count = data.clipData!!.itemCount if (resultCode == Activity.RESULT_OK && data?.clipData != null) {
for (i in 0 until count) { data.clipData?.let {
val imageUri: Uri = data.clipData!!.getItemAt(i).uri addPossibleImages(it)
photoData.add(PhotoData(imageUri))
} }
binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) }) binding.carousel.addData(photoData.map { CarouselItem(it.imageUri, it.imageDescription) })

View File

@ -13,18 +13,17 @@ import com.h.pixeldroid.R
class CarouselAdapter( class CarouselAdapter(
@LayoutRes private val itemLayout: Int, @LayoutRes private val itemLayout: Int,
@IdRes private val imageViewId: Int, @IdRes private val imageViewId: Int,
var listener: OnItemClickListener? = null, var listener: OnItemClickListener? = null,
private val imageScaleType: ImageView.ScaleType, private val imageScaleType: ImageView.ScaleType,
private val imagePlaceholder: Drawable?, private val imagePlaceholder: Drawable?,
private val carousel: Boolean private val carousel: Boolean,
var maxEntries: Int?,
) : RecyclerView.Adapter<CarouselAdapter.MyViewHolder>() { ) : RecyclerView.Adapter<CarouselAdapter.MyViewHolder>() {
private val dataList: MutableList<CarouselItem> = mutableListOf() private val dataList: MutableList<CarouselItem> = mutableListOf()
class MyViewHolder(itemView: View, imageViewId: Int) : RecyclerView.ViewHolder(itemView) { class MyViewHolder(itemView: View, imageViewId: Int) : RecyclerView.ViewHolder(itemView) {
var img: ImageView = itemView.findViewById(imageViewId) var img: ImageView = itemView.findViewById(imageViewId)
} }
@ -51,6 +50,7 @@ class CarouselAdapter(
override fun getItemCount(): Int { override fun getItemCount(): Int {
return if(carousel) dataList.size return if(carousel) dataList.size
else if (maxEntries != null && dataList.size >= maxEntries!!) maxEntries!!
else dataList.size + 1 else dataList.size + 1
} }

View File

@ -318,6 +318,12 @@ class ImageCarousel(
} }
var maxEntries: Int? = null
set(value){
field = value
adapter?.maxEntries = value
}
init { init {
@ -419,12 +425,13 @@ class ImageCarousel(
private fun initAdapter() { private fun initAdapter() {
adapter = CarouselAdapter( adapter = CarouselAdapter(
itemLayout = itemLayout, itemLayout = itemLayout,
imageViewId = imageViewId, imageViewId = imageViewId,
listener = onItemClickListener, listener = onItemClickListener,
imageScaleType = imageScaleType, imageScaleType = imageScaleType,
imagePlaceholder = imagePlaceholder, imagePlaceholder = imagePlaceholder,
carousel = layoutCarousel carousel = layoutCarousel,
maxEntries = maxEntries
) )
recyclerView.adapter = adapter recyclerView.adapter = adapter

View File

@ -1,6 +1,7 @@
package com.h.pixeldroid.posts.feeds.uncachedFeeds.accountLists package com.h.pixeldroid.posts.feeds.uncachedFeeds.accountLists
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.h.pixeldroid.utils.api.PixelfedAPI import com.h.pixeldroid.utils.api.PixelfedAPI
import com.h.pixeldroid.utils.api.objects.Account import com.h.pixeldroid.utils.api.objects.Account
import retrofit2.HttpException import retrofit2.HttpException

View File

@ -14,6 +14,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.h.pixeldroid.R import com.h.pixeldroid.R
import okhttp3.HttpUrl
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@ -22,6 +23,21 @@ fun hasInternet(context: Context): Boolean {
return cm.activeNetwork != null return cm.activeNetwork != null
} }
/**
* Check if domain is valid or not
*/
fun validDomain(domain: String?): Boolean {
domain?.apply {
try {
HttpUrl.Builder().host(replace("https://", "")).scheme("https").build()
} catch (e: IllegalArgumentException) {
return false
}
} ?: return false
return true
}
fun normalizeDomain(domain: String): String { fun normalizeDomain(domain: String): String {
return "https://" + domain return "https://" + domain
.replace("http://", "") .replace("http://", "")

View File

@ -1,5 +1,7 @@
package com.h.pixeldroid.utils.api.objects package com.h.pixeldroid.utils.api.objects
import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_MAX_TOOT_CHARS
data class Instance ( data class Instance (
val description: String?, val description: String?,
val email: String?, val email: String?,
@ -9,8 +11,4 @@ data class Instance (
val title: String?, val title: String?,
val uri: String?, val uri: String?,
val version: String? val version: String?
) { )
companion object {
const val DEFAULT_MAX_TOOT_CHARS = 500
}
}

View File

@ -1,5 +1,7 @@
package com.h.pixeldroid.utils.api.objects package com.h.pixeldroid.utils.api.objects
import com.h.pixeldroid.utils.validDomain
/* /*
See https://nodeinfo.diaspora.software/schema.html and https://pixelfed.social/api/nodeinfo/2.0.json See https://nodeinfo.diaspora.software/schema.html and https://pixelfed.social/api/nodeinfo/2.0.json
A lot of attributes we don't need are omitted, if in the future they are needed we A lot of attributes we don't need are omitted, if in the future they are needed we
@ -11,8 +13,21 @@ data class NodeInfo (
val software: Software?, val software: Software?,
val protocols: List<String>?, val protocols: List<String>?,
val openRegistrations: Boolean?, val openRegistrations: Boolean?,
val metadata: PixelfedMetadata? val metadata: PixelfedMetadata?,
){ ){
/**
* Check if this NodeInfo has the fields we need or if we also need to look into the
* /api/v1/instance endpoint
* This only checks for values that might be in the /api/v1/instance endpoint.
*/
fun hasInstanceEndpointInfo(): Boolean {
return validDomain(metadata?.config?.site?.url)
&& !metadata?.config?.site?.name.isNullOrBlank()
&& metadata?.config?.uploader?.max_caption_length?.toIntOrNull() != null
}
data class Software( data class Software(
val name: String?, val name: String?,
val version: String? val version: String?
@ -31,7 +46,8 @@ data class NodeInfo (
val open_registration: Boolean?, val open_registration: Boolean?,
val uploader: Uploader?, val uploader: Uploader?,
val activitypub: ActivityPub?, val activitypub: ActivityPub?,
val features: Features? val features: Features?,
val site: Site?
){ ){
data class Uploader( data class Uploader(
val max_photo_size: String?, val max_photo_size: String?,
@ -55,6 +71,13 @@ data class NodeInfo (
val stories: Boolean?, val stories: Boolean?,
val video: Boolean? val video: Boolean?
) )
data class Site(
val name: String?,
val domain: String?,
val url: String?,
val description: String?
)
} }
} }

View File

@ -20,7 +20,7 @@ import com.h.pixeldroid.utils.api.objects.Notification
PublicFeedStatusDatabaseEntity::class, PublicFeedStatusDatabaseEntity::class,
Notification::class Notification::class
], ],
version = 2 version = 3
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

@ -4,23 +4,20 @@ import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity
import com.h.pixeldroid.utils.db.entities.UserDatabaseEntity import com.h.pixeldroid.utils.db.entities.UserDatabaseEntity
import com.h.pixeldroid.utils.api.objects.Account import com.h.pixeldroid.utils.api.objects.Account
import com.h.pixeldroid.utils.api.objects.Instance import com.h.pixeldroid.utils.api.objects.Instance
import com.h.pixeldroid.utils.api.objects.NodeInfo
import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_ALBUM_LIMIT
import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_MAX_PHOTO_SIZE
import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_MAX_TOOT_CHARS
import com.h.pixeldroid.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_MAX_VIDEO_SIZE
import com.h.pixeldroid.utils.normalizeDomain import com.h.pixeldroid.utils.normalizeDomain
import java.lang.IllegalArgumentException
private fun normalizeOrNot(uri: String): String{
return if(uri.startsWith("http://localhost")){
uri
} else {
normalizeDomain(uri)
}
}
fun addUser(db: AppDatabase, account: Account, instance_uri: String, activeUser: Boolean = true, fun addUser(db: AppDatabase, account: Account, instance_uri: String, activeUser: Boolean = true,
accessToken: String, refreshToken: String?, clientId: String, clientSecret: String) { accessToken: String, refreshToken: String?, clientId: String, clientSecret: String) {
db.userDao().insertUser( db.userDao().insertUser(
UserDatabaseEntity( UserDatabaseEntity(
user_id = account.id!!, user_id = account.id!!,
//make sure not to normalize to https when localhost, to allow testing instance_uri = normalizeDomain(instance_uri),
instance_uri = normalizeOrNot(instance_uri),
username = account.username!!, username = account.username!!,
display_name = account.getDisplayName(), display_name = account.getDisplayName(),
avatar_static = account.avatar_static.orEmpty(), avatar_static = account.avatar_static.orEmpty(),
@ -33,14 +30,24 @@ fun addUser(db: AppDatabase, account: Account, instance_uri: String, activeUser:
) )
} }
fun storeInstance(db: AppDatabase, instance: Instance) { fun storeInstance(db: AppDatabase, nodeInfo: NodeInfo?, instance: Instance? = null) {
val maxTootChars = instance.max_toot_chars?.toInt() ?: Instance.DEFAULT_MAX_TOOT_CHARS val dbInstance: InstanceDatabaseEntity = nodeInfo?.run {
val dbInstance = InstanceDatabaseEntity( InstanceDatabaseEntity(
//make sure not to normalize to https when localhost, to allow testing uri = normalizeDomain(metadata?.config?.site?.url!!),
uri = normalizeOrNot(instance.uri.orEmpty()), title = metadata.config.site.name!!,
title = instance.title.orEmpty(), maxStatusChars = metadata.config.uploader?.max_caption_length!!.toInt(),
max_toot_chars = maxTootChars, maxPhotoSize = metadata.config.uploader.max_photo_size?.toIntOrNull() ?: DEFAULT_MAX_PHOTO_SIZE,
thumbnail = instance.thumbnail.orEmpty() //Pixelfed doesn't distinguish between max photo and video size
) maxVideoSize = metadata.config.uploader.max_photo_size?.toIntOrNull() ?: DEFAULT_MAX_VIDEO_SIZE,
albumLimit = metadata.config.uploader.album_limit?.toIntOrNull() ?: DEFAULT_ALBUM_LIMIT
)
} ?: instance?.run {
InstanceDatabaseEntity(
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")
db.instanceDao().insertInstance(dbInstance) db.instanceDao().insertInstance(dbInstance)
} }

View File

@ -6,8 +6,23 @@ import com.h.pixeldroid.utils.api.objects.Instance
@Entity(tableName = "instances") @Entity(tableName = "instances")
data class InstanceDatabaseEntity ( data class InstanceDatabaseEntity (
@PrimaryKey var uri: String, @PrimaryKey var uri: String,
var title: String = "", var title: String,
var max_toot_chars: Int = Instance.DEFAULT_MAX_TOOT_CHARS, var maxStatusChars: Int = DEFAULT_MAX_TOOT_CHARS,
var thumbnail: String = "" // 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,
) {
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
const val DEFAULT_MAX_PHOTO_SIZE = 8000
const val DEFAULT_MAX_VIDEO_SIZE = 40000
const val DEFAULT_ALBUM_LIMIT = 4
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,7v2.99s-1.99,0.01 -2,0L17,7h-3s0.01,-1.99 0,-2h3L17,2h2v3h3v2h-3zM16,11L16,8h-3L13,5L5,5c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-8h-3zM5,19l3,-4 2,3 3,-4 4,5L5,19z"
android:fillColor="#757575"/>
</vector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false"
android:drawable="@drawable/add_photo_alternate_gray_30dp" /> <!-- disabled -->
<item android:drawable="@drawable/add_photo_alternate_white_30dp" /> <!-- default -->
</selector>

View File

@ -8,14 +8,15 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/upload_error" android:id="@+id/upload_error"
android:visibility="gone"
android:layout_width="match_parent" android:layout_width="match_parent"
android:elevation="2dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:elevation="2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"> app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<com.mikepenz.iconics.view.IconicsTextView <com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/upload_error_text_view" android:id="@+id/upload_error_text_view"
@ -30,15 +31,29 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/upload_error_text_explanation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#90000000"
tools:text="Error code returned by server: 413"
android:textColor="@color/colorPrimaryError"
android:textSize="20sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upload_error_text_view"
tools:visibility="visible" />
<Button <Button
android:id="@+id/retry_upload_button" android:id="@+id/retry_upload_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/retry" android:text="@string/retry"
app:layout_constraintEnd_toEndOf="@id/upload_error_text_view" app:layout_constraintEnd_toEndOf="@id/upload_error_text_view"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="@id/upload_error_text_view" app:layout_constraintStart_toStartOf="@id/upload_error_text_view"
app:layout_constraintTop_toBottomOf="@id/upload_error_text_view" /> app:layout_constraintTop_toBottomOf="@+id/upload_error_text_explanation" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@ -135,7 +150,7 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbar3" android:id="@+id/toolbarPostCreation"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#40000000" android:background="#40000000"
@ -192,7 +207,7 @@
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_photo" android:contentDescription="@string/add_photo"
android:tooltipText='@string/add_photo' android:tooltipText='@string/add_photo'
android:src="@drawable/add_photo_alternate_white_30dp" android:src="@drawable/add_photo_button"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />

View File

@ -12,6 +12,6 @@
android:layout_height="50dp" android:layout_height="50dp"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:background="@drawable/add_photo_alternate_white_30dp" android:background="@drawable/add_photo_button"
android:contentDescription="@string/add_photo" /> android:contentDescription="@string/add_photo" />
</com.h.pixeldroid.postCreation.SquareLayout> </com.h.pixeldroid.postCreation.SquareLayout>

View File

@ -38,10 +38,11 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="domain_of_your_instance">Domain of your instance</string> <string name="domain_of_your_instance">Domain of your instance</string>
<string name="connect_to_pixelfed">Connect to Pixelfed</string> <string name="connect_to_pixelfed">Connect to Pixelfed</string>
<string name="login_connection_required_once">You need to be online to be able to add the first account and use PixelDroid :(</string> <string name="login_connection_required_once">You need to be online to be able to add the first account and use PixelDroid :(</string>
<string name="add_account_name">Add Account</string> <string name="api_not_enabled_dialog">The API is not activated on this instance. Contact your administrator to ask them to activate it.</string>
<string name="add_account_description">Add another Pixelfed Account</string>
<!-- Drawer --> <!-- Drawer -->
<string name="logout">Log out</string> <string name="logout">Log out</string>
<string name="add_account_name">Add Account</string>
<string name="add_account_description">Add another Pixelfed Account</string>
<!-- Post creation --> <!-- Post creation -->
<string name="permission_denied">Permission denied</string> <string name="permission_denied">Permission denied</string>
<string name="save_image_failed">Unable to save image</string> <string name="save_image_failed">Unable to save image</string>
@ -64,6 +65,10 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="switch_to_carousel">Switch to carousel</string> <string name="switch_to_carousel">Switch to carousel</string>
<string name="save_image_description">Save image description</string> <string name="save_image_description">Save image description</string>
<string name="no_media_description">Add a media description here…</string> <string name="no_media_description">Add a media description here…</string>
<string name="total_exceeds_album_limit">You chose more images than the maximum your server allows (%1$s). Images beyond the limit have been ignored.</string>
<string name="size_exceeds_instance_limit">Size of image number %1$s in the album exceeds the maximum size allowed by the instance (%2$s kB but the limit is %3$s kB). You might not be able to upload it.</string>
<string name="upload_error">Error code returned by server: %1$s</string>
<!-- Post editing --> <!-- Post editing -->
<string name="lbl_brightness">BRIGHTNESS</string> <string name="lbl_brightness">BRIGHTNESS</string>
@ -78,6 +83,8 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="crop_result_error">"Couldn't retrieve image after crop"</string> <string name="crop_result_error">"Couldn't retrieve image after crop"</string>
<string name="image_preview">Preview of the image being edited</string> <string name="image_preview">Preview of the image being edited</string>
<string name="crop_button">Button to crop or rotate the image</string> <string name="crop_button">Button to crop or rotate the image</string>
<string name="save_before_returning">Save your edits?</string>
<string name="no_cancel_edit">No, cancel edit</string>
<!-- Camera --> <!-- Camera -->
<string name="capture_button_alt">Capture</string> <string name="capture_button_alt">Capture</string>
@ -193,7 +200,4 @@ Following"</item>
<string name="help_translate">Help translate PixelDroid to your language:</string> <string name="help_translate">Help translate PixelDroid to your language:</string>
<string name="issues_contribute">Report issues or contribute to the application:</string> <string name="issues_contribute">Report issues or contribute to the application:</string>
<string name="mascot_description">Image showing a red panda, Pixelfed\'s mascot, using a phone</string> <string name="mascot_description">Image showing a red panda, Pixelfed\'s mascot, using a phone</string>
<string name="save_before_returning">Save your edits?</string>
<string name="no_cancel_edit">No, cancel edit</string>
</resources> </resources>