Merge branch 'view_model_fixes' into 'master'

Tablet mode

Closes #389

See merge request pixeldroid/PixelDroid!593
This commit is contained in:
Matthieu 2024-08-11 15:38:55 +00:00
commit 579c75417d
43 changed files with 1664 additions and 804 deletions

View File

@ -46,7 +46,7 @@ android {
defaultConfig {
minSdkVersion 23
targetSdkVersion 34
versionCode 33
versionCode 34
versionName "1.0.beta" + versionCode
//TODO add resConfigs("en", "fr", "ja",...) ?
@ -164,15 +164,17 @@ android {
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.0.4'
/**
* AndroidX dependencies:
*/
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
@ -182,23 +184,23 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation "androidx.lifecycle:lifecycle-common-java8:2.7.0"
implementation "androidx.annotation:annotation:1.7.1"
implementation 'androidx.paging:paging-runtime-ktx:3.3.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation "androidx.lifecycle:lifecycle-common-java8:2.8.4"
implementation "androidx.annotation:annotation:1.8.2"
implementation 'androidx.gridlayout:gridlayout:1.0.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.activity:activity-ktx:1.9.1"
implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.work:work-runtime-ktx:2.9.1'
implementation 'androidx.media2:media2-widget:1.3.0'
implementation 'androidx.media2:media2-player:1.3.0'
// Use the most recent version of CameraX
def cameraX_version = '1.3.2'
def cameraX_version = '1.3.4'
implementation "androidx.camera:camera-core:$cameraX_version"
implementation "androidx.camera:camera-camera2:$cameraX_version"
// CameraX Lifecycle library
@ -220,11 +222,11 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'com.google.android.material:material:1.12.0'
//Dagger (dependency injection)
implementation 'com.google.dagger:dagger:2.51'
ksp 'com.google.dagger:dagger-compiler:2.51'
implementation 'com.google.dagger:dagger:2.51.1'
ksp 'com.google.dagger:dagger-compiler:2.51.1'
implementation('com.google.dagger:hilt-android:2.51')
ksp 'com.google.dagger:hilt-compiler:2.51'
@ -237,7 +239,7 @@ dependencies {
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'com.github.connyduck:sparkbutton:4.1.0'
implementation 'org.pixeldroid.pixeldroid:android-media-editor:2.0'
implementation 'org.pixeldroid.pixeldroid:android-media-editor:3.1'
implementation project(path: ':scrambler')
implementation project(path: ':pixel_common')
@ -260,12 +262,6 @@ dependencies {
// Add for NavController support
implementation 'com.mikepenz:materialdrawer-nav:9.0.2'
//iconics
implementation 'com.mikepenz:iconics-core:5.4.0'
implementation 'com.mikepenz:materialdrawer-iconics:9.0.2'
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'
implementation 'me.relex:circleindicator:2.1.6'
@ -280,7 +276,7 @@ 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.9.0'
androidTestImplementation 'androidx.work:work-testing:2.9.1'
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.34.0'
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'junit:junit:4.13.2'
@ -288,13 +284,13 @@ dependencies {
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.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 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.1'
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 'com.android.support.test.espresso:espresso-contrib:3.0.2'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'

View File

@ -53,9 +53,7 @@
android:theme="@style/BaseAppTheme"/>
<activity
android:name=".posts.ReportActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/BaseAppTheme"
tools:ignore="LockedOrientationActivity" />
android:theme="@style/BaseAppTheme" />
<activity
android:name=".stories.StoriesActivity" />
@ -76,39 +74,29 @@
</activity>
<activity
android:name=".profile.FollowsActivity"
android:theme="@style/BaseAppTheme"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
android:theme="@style/BaseAppTheme" />
<activity
android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity"
android:theme="@style/BaseAppTheme"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
android:theme="@style/BaseAppTheme" />
<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=".MainActivity"
android:parentActivityName=".main.MainActivity"
android:theme="@style/BaseAppTheme" />
<activity
android:name=".MainActivity"
android:name=".main.MainActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting"
android:screenOrientation="sensorPortrait"
android:windowSoftInputMode="adjustPan"
tools:ignore="LockedOrientationActivity">
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -122,12 +110,10 @@
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".LoginActivity"
android:name=".login.LoginActivity"
android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/BaseAppTheme"
android:windowSoftInputMode="adjustResize"
tools:ignore="LockedOrientationActivity">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -143,9 +129,7 @@
android:name=".searchDiscover.SearchActivity"
android:exported="true"
android:theme="@style/BaseAppTheme"
android:launchMode="singleTop"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity">
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
@ -166,6 +150,16 @@
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>

View File

@ -1,345 +0,0 @@
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)
)
}
}

View File

@ -0,0 +1,189 @@
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.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.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 {
if (it) {
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
}
}
}
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
}
}
}

View File

@ -0,0 +1,283 @@
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()
private val _finishedLogin = MutableStateFlow(false)
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 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(R.string.verify_credentials)
}
fetchNotifications()
_finishedLogin.value = true
}
// 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)
}
}

View File

@ -0,0 +1,62 @@
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)
}

View File

@ -1,4 +1,4 @@
package org.pixeldroid.app
package org.pixeldroid.app.main
import android.Manifest
import android.content.Context
@ -16,31 +16,37 @@ 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.core.view.marginEnd
import androidx.core.view.marginTop
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.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
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.bumptech.glide.request.RequestOptions
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.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
@ -49,6 +55,8 @@ 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.login.LoginActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityMainBinding
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.posts.NestedScrollableHost
@ -70,6 +78,7 @@ import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companio
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
@ -179,22 +188,53 @@ class MainActivity : BaseActivity() {
}
private fun setupDrawer() {
binding.mainDrawerButton.setOnClickListener{
binding.drawerLayout.openDrawer(binding.drawer)
binding.mainDrawerButton?.setOnClickListener {
binding.drawer?.let { drawer -> binding.drawerLayout.openDrawer(drawer) }
}
header = AccountHeaderView(this).apply {
attachToSliderView(binding.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 ->
clickProfile(profile, current)
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
iconicsIcon = GoogleMaterial.Icon.gmd_add
iconRes = R.drawable.add
}, 0)
dividerBelowHeader = false
closeDrawerOnProfileListClick = true
@ -228,21 +268,22 @@ class MainActivity : BaseActivity() {
//with the received one. This happens asynchronously.
getUpdatedAccount()
binding.drawer.itemAdapter.add(
binding.drawer?.itemAdapter?.add(
primaryDrawerItem {
nameRes = R.string.menu_account
iconicsIcon = GoogleMaterial.Icon.gmd_person
iconRes = R.drawable.person
},
primaryDrawerItem {
nameRes = R.string.menu_settings
iconicsIcon = GoogleMaterial.Icon.gmd_settings
iconRes = R.drawable.settings
},
primaryDrawerItem {
nameRes = R.string.logout
iconicsIcon = GoogleMaterial.Icon.gmd_close
iconRes = R.drawable.logout
},
)
binding.drawer.onDrawerItemClickListener = { v, drawerItem, position ->
binding.drawer?.onDrawerItemClickListener = { v, drawerItem, position ->
when (position) {
1 -> launchActivity(ProfileActivity())
2 -> launchActivity(SettingsActivity())
@ -256,8 +297,7 @@ class MainActivity : BaseActivity() {
// Handle the back button event
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
}
else {
} else {
this.isEnabled = false
super.onBackPressedDispatcher.onBackPressed()
}
@ -305,18 +345,19 @@ class MainActivity : BaseActivity() {
}
//called when switching profiles, or when clicking on current profile
private fun clickProfile(profile: IProfile, current: Boolean): Boolean {
@Suppress("SameReturnValue")
private fun clickProfile(id: String?, instance: String?, current: Boolean): Boolean {
if(current){
launchActivity(ProfileActivity())
return false
}
//Clicked on add new account
if(profile.identifier == ADD_ACCOUNT_IDENTIFIER){
if(id == null || instance == null){
launchActivity(LoginActivity())
return false
}
switchUser(profile.identifier.toString(), profile.tag as String)
switchUser(id, instance)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -396,6 +437,34 @@ class MainActivity : BaseActivity() {
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()
}
}
private fun setupTabs(tab_array: List<() -> Fragment>){
binding.viewPager.reduceDragSensitivity()
binding.viewPager.adapter = object : FragmentStateAdapter(this) {
@ -422,37 +491,49 @@ class MainActivity : BaseActivity() {
else -> null
}
if (selected != null) {
binding.tabs.selectedItemId = selected
// 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.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
fun MenuItem.buttonPos() {
when(itemId){
R.id.my_profile -> launchActivity(ProfileActivity())
R.id.settings -> launchActivity(SettingsActivity())
R.id.log_out -> logOut()
}
}
binding.tabs.setOnItemSelectedListener {item ->
(binding.tabs as? NavigationBarView)?.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()
(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
@ -480,19 +561,12 @@ class MainActivity : BaseActivity() {
}
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.getOrCreateBadge(R.id.page_4)
if (count != null) badge.number = count
val badge = (binding.tabs as? NavigationBarView)?.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)
else (binding.tabs as? NavigationBarView)?.removeBadge(R.id.page_4)
}
/**

View File

@ -1,4 +1,4 @@
package org.pixeldroid.app
package org.pixeldroid.app.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

View File

@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import okhttp3.MultipartBody
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.utils.api.objects.Attachment

View File

@ -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.MainActivity
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityCameraBinding
import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY

View File

@ -6,7 +6,7 @@ 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.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R
@ -23,7 +23,7 @@ import org.pixeldroid.app.utils.api.objects.Status.Companion.VIEW_COMMENTS_TAG
import org.pixeldroid.app.utils.displayDimensionsInPx
class PostActivity : BaseActivity() {
private lateinit var binding: ActivityPostBinding
lateinit var binding: ActivityPostBinding
private lateinit var commentFragment: CommentFragment
@ -33,7 +33,7 @@ class PostActivity : BaseActivity() {
super.onCreate(savedInstanceState)
binding = ActivityPostBinding.inflate(layoutInflater)
commentFragment = CommentFragment(binding.swipeRefreshLayout)
commentFragment = CommentFragment()
setContentView(binding.root)
setSupportActionBar(binding.topBar)
@ -48,14 +48,15 @@ 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, displayDimensionsInPx(),
status, apiHolder, db, lifecycleScope, Pair((width*.7).toInt(), height),
requestPermissionDownloadPic, isActivity = true
)
activateCommenter()
initCommentsFragment(domain = user?.instance_uri.orEmpty())
initCommentsFragment(domain = user?.instance_uri.orEmpty(), savedInstanceState)
if(viewComments || postComment){
//Scroll already down as much as possible (since comments are not loaded yet)
@ -100,15 +101,21 @@ class PostActivity : BaseActivity() {
}
}
private fun initCommentsFragment(domain: String) {
private fun initCommentsFragment(domain: String, savedInstanceState: Bundle?) {
val arguments = Bundle()
arguments.putSerializable(COMMENT_STATUS_ID, status.id)
arguments.putSerializable(COMMENT_DOMAIN, domain)
commentFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.commentFragment, commentFragment).commit()
//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()

View File

@ -2,15 +2,22 @@ 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)
@ -24,42 +31,47 @@ 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
model.sendReport(status, binding.textInputLayout.editText?.text.toString())
}
binding.textInputLayout.editText?.isEnabled = false
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)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
model.reportSent.collect {
reportStatus(it)
}
}
}
}
private fun reportStatus(success: Boolean){
if(success){
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
} else {
}
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
}
}
}
}

View File

@ -0,0 +1,47 @@
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
}
}
}
}

View File

@ -152,8 +152,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
if(!status?.media_attachments.isNullOrEmpty()) {
setupPostPics(binding, request)
} else {
binding.postPager.visibility = View.GONE
binding.postIndicator.visibility = View.GONE
binding.postConstraint.visibility = View.GONE
}
}

View File

@ -55,16 +55,16 @@ 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) }
// }
}
override fun onCreateView(

View File

@ -40,8 +40,6 @@ 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>
@ -61,6 +59,7 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
val view = super.onCreateView(inflater, container, savedInstanceState)

View File

@ -42,15 +42,15 @@ 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) }
}
// // 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(

View File

@ -28,7 +28,7 @@ import org.pixeldroid.app.utils.setProfileImageFromURL
/**
* Fragment to show a list of [Status]s, in form of comments
*/
class CommentFragment(val swipeRefreshLayout: SwipeRefreshLayout): UncachedFeedFragment<Status>() {
class CommentFragment: UncachedFeedFragment<Status>() {
private lateinit var id: String
private lateinit var domain: String
@ -48,8 +48,10 @@ class CommentFragment(val swipeRefreshLayout: SwipeRefreshLayout): UncachedFeedF
savedInstanceState: Bundle?,
): View? {
val view = super.onCreateView(inflater, container, savedInstanceState, swipeRefreshLayout)
val view = super.onCreateView(
inflater, container, savedInstanceState,
(activity as? PostActivity)?.binding?.swipeRefreshLayout
)
// Get the view model
@Suppress("UNCHECKED_CAST")

View File

@ -1,18 +1,20 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import android.os.Bundle
import androidx.fragment.app.add
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
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
import org.pixeldroid.app.utils.BaseActivity
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 +34,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.followsFragment, args = arguments)
}
}
}

View File

@ -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()

View File

@ -1,6 +1,8 @@
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
@ -12,10 +14,8 @@ 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.followsFragment, args = arguments)
}
}
}

View File

@ -15,7 +15,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.SettingsBinding
import org.pixeldroid.common.ThemedActivity

View File

@ -1,13 +1,29 @@
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
@HiltAndroidApp
class PixelDroidApplication: Application() {
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()
override fun onCreate() {
super.onCreate()

View File

@ -12,36 +12,41 @@ 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 org.pixeldroid.app.MainActivity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.pixeldroid.app.main.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.*
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.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
class NotificationsWorker(
context: Context,
params: WorkerParameters
@HiltWorker
class NotificationsWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val db: AppDatabase,
private val apiHolder: PixelfedAPIHolder
) : CoroutineWorker(context, params) {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override suspend fun doWork(): Result {
val users: List<UserDatabaseEntity> = db.userDao().getAll()

View File

@ -0,0 +1,5 @@
<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>

View File

@ -0,0 +1,5 @@
<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>

View File

@ -0,0 +1,5 @@
<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="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<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.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/main_activity_main_linear_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/main_drawer_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:contentDescription="@string/open_drawer_menu"
android:src="@drawable/ic_baseline_menu_24" />
<com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/tabs"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/main_activity_main_linear_layout"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
android:id="@+id/drawer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp">
<ImageView
android:id="@+id/profilePic"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/profile_picture"
android:src="@drawable/ic_default_user"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/profilePic"
app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="username" />
<TextView
android:id="@+id/postDomain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#b3b3b3"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/username"
app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="from domain.tld" />
<ImageButton
android:id="@+id/status_more"
style="?android:attr/actionOverflowButtonStyle"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/status_more_options"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/profilePic" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/postConstraint"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@+id/postDetails"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profilePic"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.7">
<org.pixeldroid.app.posts.NestedScrollableHost
android:id="@+id/postPagerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/postPager"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</org.pixeldroid.app.posts.NestedScrollableHost>
<me.relex.circleindicator.CircleIndicator3
android:id="@+id/postIndicator"
android:layout_width="wrap_content"
android:layout_height="32dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/like_animation"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/heart_anim"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/post_fragment_image_popup_menu_anchor"
android:layout_width="1dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="@+id/postPagerHost"
app:layout_constraintHorizontal_bias="0.1"
app:layout_constraintStart_toStartOf="@+id/postPagerHost"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
app:layout_constraintVertical_bias="0.1" />
<TextView
android:id="@+id/sensitiveWarning"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:background="@drawable/rounded_corner"
android:gravity="center|center_horizontal|center_vertical"
android:text="@string/cw_nsfw_hidden_media_n_click_to_show"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="@color/ic_launcher_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/postDetails"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/postConstraint"
app:layout_constraintTop_toTopOf="@id/postConstraint">
<ImageView
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/add_comment"
android:padding="4dp"
android:src="@drawable/selector_commenter"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.20"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/commenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/commenter"
app:layout_constraintTop_toTopOf="@+id/commenter"
sparkbutton:activeImage="@drawable/ic_reblog_blue"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_reblog"
sparkbutton:primaryColor="@color/share_blue"
sparkbutton:secondaryColor="@color/black" />
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
app:layout_constraintEnd_toEndOf="@+id/liker"
app:layout_constraintStart_toStartOf="@+id/liker"
app:layout_constraintTop_toBottomOf="@+id/liker"
tools:text="2 Likes" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
android:gravity="end"
app:layout_constraintEnd_toEndOf="@+id/reblogger"
app:layout_constraintStart_toStartOf="@+id/reblogger"
app:layout_constraintTop_toBottomOf="@+id/reblogger"
tools:text="3 Shares" />
<TextView
android:id="@+id/usernameDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/usernameDesc"
tools:text="This is a description, describing stuff.\nIt contains multiple lines, and that's okay. It's also got some really long lines, and we love it for it." />
<TextView
android:id="@+id/postDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="#b3b3b3"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/description"
tools:text="Yesterday" />
<TextView
android:id="@+id/viewComments"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@+id/postDate"
app:layout_constraintTop_toBottomOf="@+id/postDate"
app:layout_constraintVertical_bias="0.0"
tools:text="3 comments" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainConstraint"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/accountList"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?attr/colorSurfaceContainerLow"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:elevation="10dp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/navigation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:headerLayout="@layout/header_navigation"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:menu="@menu/navigation_main" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/navigation"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainConstraint"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/main_activity_main_linear_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/main_drawer_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/colorSurfaceContainer"
android:contentDescription="@string/open_drawer_menu"
android:padding="12dp"
android:src="@drawable/ic_baseline_menu_24" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/tabs"
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/main_activity_main_linear_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
android:id="@+id/drawer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/imageView"
android:layout_width="50dp"
android:layout_margin="12dp"
android:layout_height="50dp"
tools:src="@drawable/ic_launcher_foreground"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/accountName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Account name"
android:layout_marginStart="12dp"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@id/accountUsername"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView" />
<TextView
android:id="@+id/accountUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
tools:text="\@account@instance.tld"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toBottomOf="@+id/accountName" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".LoginActivity">
tools:context=".login.LoginActivity">
<ScrollView
android:layout_width="match_parent"
@ -67,30 +67,6 @@
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/login_activity_connection_required"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_connection_required_once"
android:textAlignment="center"/>
<Button
android:id="@+id/login_activity_connection_required_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:text="@string/retry"/>
</LinearLayout>
<LinearLayout
android:id="@+id/progressLayout"
android:layout_width="match_parent"

View File

@ -5,7 +5,7 @@
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.MainActivity">
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
@ -37,7 +37,7 @@
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/bottom_navigation_main" />
app:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.mikepenz.materialdrawer.widget.AccountHeaderView android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/headerview"/>

View File

@ -1,24 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto">
android:layout_marginBottom="5dp">
<ImageView
android:id="@+id/profilePic"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/profile_picture"
android:src="@drawable/ic_default_user"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/profile_picture" />
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/username"
@ -60,10 +60,10 @@
android:id="@+id/postPager"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:orientation="horizontal" />
app:layout_constraintTop_toTopOf="parent" />
</org.pixeldroid.app.posts.NestedScrollableHost>
@ -123,13 +123,13 @@
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/add_comment"
android:padding="4dp"
android:src="@drawable/selector_commenter"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker"
android:contentDescription="@string/add_comment" />
app:layout_constraintTop_toTopOf="@id/liker" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
@ -139,15 +139,15 @@
android:layout_marginBottom="4dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="@id/profilePic"
app:layout_constraintTop_toBottomOf="@id/postConstraint"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="@id/profilePic"
app:layout_constraintTop_toBottomOf="@id/postConstraint"/>
sparkbutton:secondaryColor="@color/black" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
@ -167,12 +167,12 @@
<ImageButton
android:id="@+id/status_more"
style="?android:attr/actionOverflowButtonStyle"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/status_more_options"
android:padding="4dp"
style="?android:attr/actionOverflowButtonStyle"
app:layout_constraintBottom_toBottomOf="@+id/postDomain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/postDomain" />
@ -200,8 +200,8 @@
<TextView
android:id="@+id/usernameDesc"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/profilePic"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account" />

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:id="@+id/tabsId">
<item
android:id="@+id/page_1"
android:enabled="true"
android:icon="@drawable/selector_home_feed"
android:title="@string/home_feed"/>
<item
android:id="@+id/page_2"
android:enabled="true"
android:icon="@drawable/ic_search_white_24dp"
android:title="@string/search_discover_feed"/>
<item
android:id="@+id/page_3"
android:enabled="true"
android:icon="@drawable/selector_camera"
android:title="@string/create_feed"/>
<item
android:id="@+id/page_4"
android:enabled="true"
android:icon="@drawable/selector_notifications"
android:title="@string/notifications_feed"/>
<item
android:id="@+id/page_5"
android:enabled="true"
android:icon="@drawable/ic_filter_black_24dp"
android:title="@string/public_feed"/>
</group>
<item
android:id="@+id/my_profile"
android:enabled="true"
android:icon="@drawable/person"
android:title="@string/menu_account"/>
<item
android:id="@+id/settings"
android:enabled="true"
android:icon="@drawable/settings"
android:title="@string/menu_settings"/>
<item
android:id="@+id/log_out"
android:enabled="true"
android:icon="@drawable/logout"
android:title="@string/logout"/>
</menu>

View File

@ -180,8 +180,8 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<item quantity="other">"%d\nFollowing"</item>
</plurals>
<string name="edit">Edit</string>
<string name="save_image_failed">Unable to save image</string>
<string name="save_image_success">Image successfully saved</string>
<string name="save_image_failed">Unable to save</string>
<string name="save_image_success">Saved successfully</string>
<string name="follow_status_failed">Could not get follow status</string>
<string name="edit_link_failed">Failed to open edit page</string>
<string name="new_collection_link_failed">Failed to open collection creation page</string>

View File

@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.3.1'
classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@ -0,0 +1,4 @@
* Support for tablets, and rotation of all screens. There could still be some issues, please send us a message if there are
* Bug fixes: notably, fix notifications which broke a couple releases back.
* Translation updates
* Update dependencies

View File

@ -2,6 +2,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists