initial commit

This commit is contained in:
Conny Duck 2020-06-12 15:44:45 +02:00
commit 99038aa419
175 changed files with 6830 additions and 0 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @connyduck

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
open_collective: pixelcat

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
/.DS_Store
/build
/captures
.externalNativeBuild
*.apk
*.aab
output.json

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

114
app/build.gradle.kts Normal file
View File

@ -0,0 +1,114 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
id("kotlin-kapt")
}
android {
compileSdkVersion(29)
defaultConfig {
applicationId = "at.connyduck.pixelcat"
minSdkVersion(23)
targetSdkVersion(29)
versionCode = 1
versionName = "0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
androidExtensions {
features = setOf("parcelize")
}
buildFeatures {
viewBinding = true
}
}
android.sourceSets["main"].java.srcDir("src/main/kotlin")
tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "1.8"
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
}
dependencies {
val lifecycleVersion = "2.3.0-alpha04"
val roomVersion = "2.3.0-alpha01"
val okHttpVersion = "4.7.2"
val retrofitVersion = "2.9.0"
val moshiVersion = "1.9.2"
val daggerVersion = "2.27"
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72")
implementation("androidx.core:core:1.3.0")
implementation("androidx.appcompat:appcompat:1.3.0-alpha01")
implementation("androidx.activity:activity-ktx:1.2.0-alpha06")
implementation("androidx.fragment:fragment-ktx:1.3.0-alpha06")
implementation("com.google.android.material:material:1.2.0-beta01")
implementation("androidx.constraintlayout:constraintlayout:1.1.3")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01")
implementation("androidx.recyclerview:recyclerview:1.2.0-alpha03")
implementation("androidx.annotation:annotation:1.1.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
implementation("androidx.preference:preference:1.1.1")
implementation("androidx.emoji:emoji-bundled:1.1.0-rc01")
implementation("androidx.paging:paging-runtime-ktx:3.0.0-alpha01")
implementation("androidx.viewpager2:viewpager2:1.0.0")
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
kapt("androidx.room:room-compiler:$roomVersion")
implementation("com.squareup.okhttp3:okhttp:$okHttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okHttpVersion")
implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation("com.squareup.retrofit2:converter-moshi:$retrofitVersion")
implementation("com.squareup.moshi:moshi-kotlin:$moshiVersion")
implementation("com.squareup.moshi:moshi-adapters:$moshiVersion")
kapt("com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
implementation("com.google.dagger:dagger:2.28")
implementation("com.fxn769:pix:1.4.4")
implementation( "com.github.yalantis:ucrop:2.2.5")
implementation("me.relex:circleindicator:2.1.4")
implementation("io.coil-kt:coil:0.11.0")
implementation("com.github.connyduck:sparkbutton:4.0.0")
implementation("com.google.dagger:dagger:$daggerVersion")
kapt("com.google.dagger:dagger-compiler:$daggerVersion")
implementation("com.google.dagger:dagger-android:$daggerVersion")
implementation("com.google.dagger:dagger-android-support:$daggerVersion")
kapt("com.google.dagger:dagger-android-processor:$daggerVersion")
testImplementation("junit:junit:4.13")
implementation("com.facebook.stetho:stetho:1.5.1")
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="at.connyduck.pixelcat">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".PixelcatApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="false"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
<!-- TODO order activities -->
<activity android:name=".components.splash.SplashActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name=".components.login.LoginWebViewActivity">
</activity>
<activity
android:name=".components.settings.SettingsActivity"
android:label="@string/title_activity_settings">
</activity>
<activity
android:name=".components.login.LoginActivity"
android:theme="@style/AppTheme.Fullscreen">
</activity>
<activity
android:name=".components.main.MainActivity"
android:label="@string/app_name">
</activity>
<activity
android:name=".components.about.AboutActivity"
android:theme="@style/AppTheme.Fullscreen"/>
<activity android:name=".components.about.licenses.LicenseActivity"/>
<activity android:name="at.connyduck.pixelcat.components.compose.ComposeActivity" />
<activity android:name=".components.profile.ProfileActivity" />
<service android:name=".components.compose.SendStatusService" />
</application>
</manifest>

View File

@ -0,0 +1,38 @@
package at.connyduck.pixelcat
import androidx.appcompat.app.AppCompatDelegate
import androidx.emoji.bundled.BundledEmojiCompatConfig
import androidx.emoji.text.EmojiCompat
import at.connyduck.pixelcat.components.settings.AppSettings
import at.connyduck.pixelcat.dagger.DaggerAppComponent
import com.facebook.stetho.Stetho
import dagger.android.AndroidInjector
import dagger.android.DaggerApplication
import okhttp3.OkHttpClient
import javax.inject.Inject
class PixelcatApplication : DaggerApplication() {
@Inject
lateinit var appSettings: AppSettings
@Inject
lateinit var okhttpClient: OkHttpClient
override fun onCreate() {
super.onCreate()
Stetho.initializeWithDefaults(this)
AppCompatDelegate.setDefaultNightMode(appSettings.getNightMode())
EmojiCompat.init(BundledEmojiCompatConfig(this))
}
override fun applicationInjector(): AndroidInjector<PixelcatApplication> {
return DaggerAppComponent.builder()
.application(this)
.build()
}
}

View File

@ -0,0 +1,49 @@
package at.connyduck.pixelcat.components.about
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import at.connyduck.pixelcat.BuildConfig
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.about.licenses.LicenseActivity
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.databinding.ActivityAboutBinding
class AboutActivity : BaseActivity() {
private lateinit var binding: ActivityAboutBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.aboutToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
setSupportActionBar(binding.aboutToolbar)
binding.aboutToolbar.setNavigationOnClickListener {
onBackPressed()
}
binding.aboutAppVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
binding.aboutLicensesButton.setOnClickListener {
startActivity(LicenseActivity.newIntent(this))
}
}
companion object {
fun newIntent(context: Context) = Intent(context, AboutActivity::class.java)
}
}

View File

@ -0,0 +1,70 @@
package at.connyduck.pixelcat.components.about.licenses
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.RawRes
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.databinding.ActivityLicenseBinding
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
class LicenseActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityLicenseBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.licenseContainer.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.licenseToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
setSupportActionBar(binding.licenseToolbar)
binding.licenseToolbar.setNavigationOnClickListener {
onBackPressed()
}
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
}
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
val sb = StringBuilder()
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
try {
var line: String? = br.readLine()
while (line != null) {
sb.append(line)
sb.append('\n')
line = br.readLine()
}
} catch (e: IOException) {
Log.w("LicenseActivity", e)
}
br.close()
textView.text = sb.toString()
}
companion object {
fun newIntent(context: Context) = Intent(context, LicenseActivity::class.java)
}
}

View File

@ -0,0 +1,44 @@
package at.connyduck.pixelcat.components.about.licenses
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.extension.hide
import at.connyduck.pixelcat.databinding.CardLicenseBinding
import com.google.android.material.card.MaterialCardView
class LicenseCard
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : MaterialCardView(context, attrs, defStyleAttr) {
private val binding =
CardLicenseBinding.inflate(LayoutInflater.from(context), this)
init {
val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0)
val name: String? = a.getString(R.styleable.LicenseCard_name)
val license: String? = a.getString(R.styleable.LicenseCard_license)
val link: String? = a.getString(R.styleable.LicenseCard_link)
a.recycle()
radius = resources.getDimension(R.dimen.license_card_radius)
binding.licenseCardName.text = name
binding.licenseCardLicense.text = license
if(link.isNullOrBlank()) {
binding.licenseCardLink.hide()
} else {
binding.licenseCardLink.text = link
// setOnClickListener { LinkHelper.openLink(link, context) }
}
}
}

View File

@ -0,0 +1,60 @@
package at.connyduck.pixelcat.components.bottomsheet.accountselection
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.extension.hide
import at.connyduck.pixelcat.components.util.extension.show
import at.connyduck.pixelcat.databinding.ItemAccountSelectionBinding
import at.connyduck.pixelcat.db.entitity.AccountEntity
import coil.api.load
import coil.transform.RoundedCornersTransformation
class AccountSelectionAdapter(
private val accounts: List<AccountEntity>,
private val onAccountSelected: (Long) -> Unit,
private val onAddAccount: () -> Unit
) : RecyclerView.Adapter<AccountSelectionViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountSelectionViewHolder {
val binding =
ItemAccountSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AccountSelectionViewHolder(binding)
}
override fun getItemCount() = accounts.size + 1
override fun onBindViewHolder(holder: AccountSelectionViewHolder, position: Int) {
val binding = holder.binding
if (position == accounts.size) {
binding.accountAvatar.load(R.drawable.ic_plus_background)
binding.accountName.hide()
binding.accountDisplayName.setText(R.string.action_add_new_account)
binding.root.isSelected = false
binding.root.setOnClickListener {
onAddAccount()
}
} else {
val account = accounts[position]
binding.accountAvatar.load(account.profilePictureUrl) {
transformations(RoundedCornersTransformation(25f))
}
binding.accountDisplayName.text = account.displayName
binding.accountName.show()
binding.accountName.text = account.fullName
binding.root.isSelected = account.isActive
binding.root.setOnClickListener {
if (!account.isActive) {
onAccountSelected(account.id)
}
}
}
}
}
class AccountSelectionViewHolder(val binding: ItemAccountSelectionBinding) :
RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,62 @@
package at.connyduck.pixelcat.components.bottomsheet.accountselection
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import at.connyduck.pixelcat.components.login.LoginActivity
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.databinding.BottomsheetAccountsBinding
import at.connyduck.pixelcat.db.AccountManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
class AccountSelectionBottomSheet(
private val accountManager: AccountManager
) : BottomSheetDialogFragment() {
private var _binding: BottomsheetAccountsBinding? = null
private val binding
get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomsheetAccountsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleScope.launch {
binding.accountsRecyclerView.adapter = AccountSelectionAdapter(accountManager.getAllAccounts(), ::onAccountSelected, ::onNewAccount)
}
}
private fun onAccountSelected(accountId: Long) {
lifecycleScope.launch {
accountManager.setActiveAccount(accountId)
val intent = Intent(requireContext(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
activity?.finish()
}
}
private fun onNewAccount() {
//TODO don't create intent here
startActivity(Intent(requireContext(), LoginActivity::class.java))
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,46 @@
package at.connyduck.pixelcat.components.bottomsheet.menu
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import at.connyduck.pixelcat.components.about.AboutActivity
import at.connyduck.pixelcat.components.settings.SettingsActivity
import at.connyduck.pixelcat.databinding.BottomsheetMenuBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class MenuBottomSheet : BottomSheetDialogFragment() {
private var _binding: BottomsheetMenuBinding? = null
private val binding
get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomsheetMenuBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.menuSettings.setOnClickListener {
startActivity(SettingsActivity.newIntent(requireActivity()))
dismiss()
}
binding.menuAbout.setOnClickListener {
startActivity(AboutActivity.newIntent(it.context))
dismiss()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,122 @@
package at.connyduck.pixelcat.components.compose
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.ActivityComposeBinding
import at.connyduck.pixelcat.util.viewBinding
import com.fxn.pix.Pix
import com.google.android.material.bottomsheet.BottomSheetBehavior
import javax.inject.Inject
class ComposeActivity: BaseActivity() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(ActivityComposeBinding::inflate)
private val adapter = ComposeImageAdapter()
private lateinit var visibilityBottomSheet: BottomSheetBehavior<*>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.composeAppBar.layoutParams as CoordinatorLayout.LayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
if(viewModel.images.value.isNullOrEmpty()) {
viewModel.addImage(intent.getStringExtra(EXTRA_MEDIA_URI)!!)
}
setSupportActionBar(binding.composeToolBar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.composeImages.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.composeImages.adapter = adapter
visibilityBottomSheet = BottomSheetBehavior.from(binding.composeVisibilityBottomSheet)
binding.composeShareButton.setOnClickListener {
viewModel.sendStatus()
}
binding.composeVisibilityButton.setOnClickListener {
visibilityBottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
}
binding.composeVisibilityPublic.setOnClickListener {
changeVisibility(VISIBILITY.PUBLIC)
}
binding.composeVisibilityUnlisted.setOnClickListener {
changeVisibility(VISIBILITY.UNLISTED)
}
binding.composeVisibilityFollowers.setOnClickListener {
changeVisibility(VISIBILITY.FOLLOWERS_ONLY)
}
viewModel.images.observe(this, Observer {
adapter.submitList(it)
})
viewModel.visibility.observe(this, Observer {
val visibilityString = when(it) {
VISIBILITY.PUBLIC -> R.string.compose_visibility_public
VISIBILITY.UNLISTED -> R.string.compose_visibility_unlisted
VISIBILITY.FOLLOWERS_ONLY -> R.string.compose_visibility_followers_only
}
binding.composeVisibilityButton.text = getString(R.string.compose_visibility, getString(visibilityString))
})
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_PICK_MEDIA) {
val returnValue =
data?.getStringArrayListExtra(Pix.IMAGE_RESULTS)
Log.e("Result", returnValue.toString())
viewModel.addImage(returnValue?.first()!!)
}
}
private fun changeVisibility(visibility: VISIBILITY) {
viewModel.setVisibility(visibility)
visibilityBottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
}
companion object {
private const val REQUEST_CODE_PICK_MEDIA = 123
private const val EXTRA_MEDIA_URI = "MEDIA_URI"
fun newIntent(context: Context, mediaUri: String): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(EXTRA_MEDIA_URI, mediaUri)
}
}
}
}

View File

@ -0,0 +1,44 @@
package at.connyduck.pixelcat.components.compose
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.databinding.ItemComposeImageBinding
import coil.api.load
import java.io.File
class ComposeImageAdapter : ListAdapter<String, ComposeImageViewHolder>(
object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(old: String, new: String): Boolean {
return old == new
}
override fun areContentsTheSame(old: String, new: String): Boolean {
return old == new
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ComposeImageViewHolder {
val binding =
ItemComposeImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ComposeImageViewHolder(binding)
}
override fun onBindViewHolder(holder: ComposeImageViewHolder, position: Int) {
getItem(position)?.let { uri ->
holder.binding.root.load(File(uri)) {
placeholder(R.drawable.ic_cat)
error(R.drawable.ic_message)
}
}
}
}
class ComposeImageViewHolder(val binding: ItemComposeImageBinding) :
RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,60 @@
package at.connyduck.pixelcat.components.compose
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.pixelcat.db.AccountManager
import kotlinx.coroutines.launch
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
val context: Context,
val accountManager: AccountManager
): ViewModel() {
val images = MutableLiveData<List<String>>()
val visibility = MutableLiveData(VISIBILITY.PUBLIC)
fun addImage(imageUri: String) {
images.value = images.value.orEmpty() + imageUri
}
fun setVisibility(visibility: VISIBILITY) {
this.visibility.value = visibility
}
fun sendStatus() {
viewModelScope.launch {
val statusToSend = StatusToSend(
accountId = accountManager.activeAccount()!!.id,
text = "test",
visibility = visibility.value!!.serverName,
sensitive = false,
mediaUris = images.value!!
)
val intent = SendStatusService.sendStatusIntent(context, statusToSend)
ContextCompat.startForegroundService(context, intent)
}
}
}
enum class VISIBILITY(val serverName: String) {
PUBLIC("public"),
UNLISTED("unlisted"),
FOLLOWERS_ONLY("private")
}

View File

@ -0,0 +1,272 @@
package at.connyduck.pixelcat.components.compose
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.getColorForAttr
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.model.NewStatus
import at.connyduck.pixelcat.network.FediverseApi
import at.connyduck.pixelcat.network.calladapter.NetworkResponseError
import dagger.android.DaggerService
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class SendStatusService : DaggerService(), CoroutineScope {
@Inject
lateinit var api: FediverseApi
@Inject
lateinit var accountManager: AccountManager
private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
private val sendJobs = ConcurrentHashMap<Int, Job>()
private val timer = Timer()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) {
val tootToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
?: throw IllegalStateException("SendTootService started without $KEY_STATUS extra")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_status_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_title))
.setContentText(tootToSend.text)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(getColorForAttr(android.R.attr.colorPrimary))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {
notificationManager.notify(sendingNotificationId, builder.build())
}
statusesToSend[sendingNotificationId] = tootToSend
sendStatus(sendingNotificationId--)
} else {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
}
}
return START_NOT_STICKY
}
private fun sendStatus(id: Int) {
// when tootToSend == null, sending has been canceled
val statusToSend = statusesToSend[id] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(statusToSend.accountId)
if (account == null) {
statusesToSend.remove(id)
notificationManager.cancel(id)
stopSelfWhenDone()
return
}
statusToSend.retries++
launch {
val mediaIds = statusToSend.mediaUris.map {
var type: String? = null
val extension = MimeTypeMap.getFileExtensionFromUrl(it)
if (extension != null) {
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
val file = File(it)
val filePart = file.asRequestBody(type!!.toMediaType())
val body = MultipartBody.Part.create(filePart)
api.uploadMedia(body).fold({ attachment ->
attachment.id
}, {
""
})
}
val newStatus = NewStatus(
status = statusToSend.text,
inReplyToId = null,
visibility = statusToSend.visibility,
sensitive = statusToSend.sensitive,
mediaIds = mediaIds
)
api.createStatus(
"Bearer " + account.auth.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus
).fold<Any?>({
statusesToSend.remove(id)
}, {
when(it) {
is NetworkResponseError.ApiError -> {
// the server refused to accept the status, save toot & show error message
// TODO saveToDrafts
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_error_title))
//.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(getColorForAttr(android.R.attr.colorPrimary))
notificationManager.cancel(id)
notificationManager.notify(errorNotificationId--, builder.build())
}
else -> {
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(object : TimerTask() {
override fun run() {
sendStatus(id)
}
}, backoff)
}
}
})
}.apply {
sendJobs[id] = this
}
}
private fun stopSelfWhenDone() {
if (statusesToSend.isEmpty()) {
coroutineContext.cancel()
ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun cancelSending(id: Int) {
val statusToCancel = statusesToSend.remove(id)
if (statusToCancel != null) {
val sendCall = sendJobs.remove(id)
sendCall?.cancel()
// saveTootToDrafts(tootToCancel)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_cat)
.setContentTitle(getString(R.string.send_status_notification_cancel_title))
// .setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(getColorForAttr(android.R.attr.colorPrimary))
notificationManager.notify(id, builder.build())
timer.schedule(object : TimerTask() {
override fun run() {
notificationManager.cancel(id)
stopSelfWhenDone()
}
}, 5000)
}
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
val intent = Intent(this, SendStatusService::class.java)
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
companion object {
private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_status"
private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1)
private var sendingNotificationId = -1 // use negative ids to not clash with other notis
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
@JvmStatic
fun sendStatusIntent(context: Context,
statusToSend: StatusToSend
): Intent {
val intent = Intent(context, SendStatusService::class.java)
intent.putExtra(KEY_STATUS, statusToSend)
return intent
}
}
override val coroutineContext: CoroutineContext
get() = SupervisorJob() + Dispatchers.IO
}
@Parcelize
data class StatusToSend(
val accountId: Long,
val idempotencyKey: String = UUID.randomUUID().toString(),
val text: String,
val visibility: String,
val sensitive: Boolean,
val mediaUris: List<String>,
val mediaDescriptions: List<String> = emptyList(),
val inReplyToId: String? = null,
val savedTootUid: Int = 0,
var retries: Int = 0
) : Parcelable

View File

@ -0,0 +1,24 @@
package at.connyduck.pixelcat.components.general
import android.os.Bundle
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.settings.AppSettings
import dagger.android.support.DaggerAppCompatActivity
import javax.inject.Inject
abstract class BaseActivity: DaggerAppCompatActivity() {
@Inject
lateinit var appSettings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
theme.applyStyle(appSettings.getAppColorStyle(), true)
if(!appSettings.useSystemFont()) {
theme.applyStyle(R.style.NunitoFont, true)
}
}
}

View File

@ -0,0 +1,118 @@
package at.connyduck.pixelcat.components.login
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.about.AboutActivity
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.components.settings.SettingsActivity
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.ActivityLoginBinding
import at.connyduck.pixelcat.util.viewBinding
import javax.inject.Inject
class LoginActivity : BaseActivity(), Observer<LoginModel> {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val loginViewModel: LoginViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(ActivityLoginBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.loginContainer.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.loginToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
setSupportActionBar(binding.loginToolbar)
supportActionBar?.run {
setDisplayShowTitleEnabled(false)
}
loginViewModel.loginState.observe(this, this)
binding.loginButton.setOnClickListener {
loginViewModel.startLogin(binding.loginInput.text.toString())
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val authCode = data?.getStringExtra(LoginWebViewActivity.RESULT_AUTHORIZATION_CODE)
if(requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK && !authCode.isNullOrEmpty()) {
loginViewModel.authCode(authCode)
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.login, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) {
R.id.navigation_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
}
R.id.navigation_info -> {
startActivity(AboutActivity.newIntent(this))
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onChanged(loginModel: LoginModel?) {
binding.loginInput.setText(loginModel?.input)
if(loginModel == null) {
return
}
when(loginModel.state) {
LoginState.NO_ERROR -> binding.loginInputLayout.error = null
LoginState.AUTH_ERROR -> binding.loginInputLayout.error = "auth error"
LoginState.INVALID_DOMAIN -> binding.loginInputLayout.error = "invalid domain"
LoginState.NETWORK_ERROR -> binding.loginInputLayout.error = "network error"
LoginState.LOADING -> {
}
LoginState.SUCCESS -> {
startActivityForResult(LoginWebViewActivity.newIntent(loginModel.domain!!, loginModel.clientId!!, loginModel.clientSecret!!, this), REQUEST_CODE)
}
LoginState.SUCCESS_FINAL -> {
startActivity(Intent(this, MainActivity::class.java)) // TODO dont create intent here
finish()
}
}
}
companion object {
private const val REQUEST_CODE = 14
}
}

View File

@ -0,0 +1,19 @@
package at.connyduck.pixelcat.components.login
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class LoginModel(
val input: CharSequence = "",
val state: LoginState = LoginState.NO_ERROR,
val domain: String? = null,
val clientId: String? = null,
val clientSecret: String? = null
): Parcelable
enum class LoginState { //TODO rename this stuff so it makes sense
LOADING, NO_ERROR, NETWORK_ERROR, INVALID_DOMAIN, AUTH_ERROR, SUCCESS, SUCCESS_FINAL
}

View File

@ -0,0 +1,108 @@
package at.connyduck.pixelcat.components.login
import androidx.annotation.MainThread
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.pixelcat.config.Config
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.db.entitity.AccountAuthData
import at.connyduck.pixelcat.network.FediverseApi
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import java.util.*
import javax.inject.Inject
class LoginViewModel @Inject constructor(
private val fediverseApi: FediverseApi,
private val accountManager: AccountManager
): ViewModel() {
val loginState = MutableLiveData<LoginModel>().apply {
value = LoginModel(state = LoginState.NO_ERROR)
}
@MainThread
fun startLogin(input: String) {
val domainInput = canonicalizeDomain(input)
try {
HttpUrl.Builder().host(domainInput).scheme("https").build()
} catch (e: IllegalArgumentException) {
loginState.value = LoginModel(input, LoginState.INVALID_DOMAIN)
return
}
val exceptionMatch = Config.domainExceptions.any {exception ->
domainInput.equals(exception, true) || domainInput.endsWith(".$exception", true)
}
if(exceptionMatch) {
loginState.value = LoginModel(input, LoginState.AUTH_ERROR)
return
}
loginState.value = LoginModel(input, LoginState.LOADING)
viewModelScope.launch {
fediverseApi.authenticateAppAsync(
domain = domainInput,
clientName = "Pixelcat",
clientWebsite = Config.website,
redirectUris = Config.oAuthRedirect,
scopes = Config.oAuthScopes
).fold({ appData ->
loginState.postValue(LoginModel(input, LoginState.SUCCESS, domainInput, appData.clientId, appData.clientSecret))
}, {
loginState.postValue(LoginModel(input, LoginState.AUTH_ERROR))
})
}
}
@MainThread
fun authCode(authCode: String) {
viewModelScope.launch {
val loginModel = loginState.value!!
fediverseApi.fetchOAuthToken(
domain = loginModel.domain!!,
clientId = loginModel.clientId!!,
clientSecret = loginModel.clientSecret!!,
redirectUri = Config.oAuthRedirect,
code = authCode
).fold({ tokenResponse ->
val authData = AccountAuthData(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
tokenExpiresAt = tokenResponse.createdAt ?: 0 + (tokenResponse.expiresIn
?: 0),
clientId = loginModel.clientId,
clientSecret = loginModel.clientSecret
)
accountManager.addAccount(loginModel.domain, authData)
loginState.postValue(loginState.value?.copy(state = LoginState.SUCCESS_FINAL))
}, {
})
}
}
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim().toLowerCase(Locale.ROOT)
}
}

View File

@ -0,0 +1,83 @@
package at.connyduck.pixelcat.components.login
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebView
import at.connyduck.pixelcat.config.Config
import android.webkit.WebViewClient
import at.connyduck.pixelcat.databinding.ActivityLoginWebViewBinding
class LoginWebViewActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginWebViewBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginWebViewBinding.inflate(layoutInflater)
setContentView(binding.root)
val domain = intent.getStringExtra(EXTRA_DOMAIN)
val clientId = intent.getStringExtra(EXTRA_CLIENT_ID)
val clientSecret = intent.getStringExtra(EXTRA_CLIENT_SECRET)
val endpoint = "/oauth/authorize"
val parameters = mapOf(
"client_id" to clientId,
"redirect_uri" to Config.oAuthRedirect,
"response_type" to "code",
"scope" to Config.oAuthScopes
)
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
binding.loginWebView.webViewClient = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if(request.url.scheme == Config.oAuthScheme && request.url.host == Config.oAuthHost) {
loginSuccess(request.url.getQueryParameter("code").orEmpty())
return true
}
return false
}
}
binding.loginWebView.loadUrl(url)
}
private fun loginSuccess(authCode: String) {
val successIntent = Intent().apply {
putExtra(RESULT_AUTHORIZATION_CODE, authCode)
}
setResult(Activity.RESULT_OK, successIntent)
finish()
}
private fun toQueryString(parameters: Map<String, String>): String {
return parameters.map { "${it.key}=${Uri.encode(it.value)}" }
.joinToString("&")
}
companion object {
const val RESULT_AUTHORIZATION_CODE = "authCode"
private const val EXTRA_DOMAIN = "domain"
private const val EXTRA_CLIENT_ID = "clientId"
private const val EXTRA_CLIENT_SECRET = "clientSecret"
fun newIntent(domain: String, clientId: String, clientSecret: String, context: Context): Intent {
return Intent(context, LoginWebViewActivity::class.java).apply {
putExtra(EXTRA_DOMAIN, domain)
putExtra(EXTRA_CLIENT_ID, clientId)
putExtra(EXTRA_CLIENT_SECRET, clientSecret)
}
}
}
}

View File

@ -0,0 +1,105 @@
package at.connyduck.pixelcat.components.main
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.LinearLayout
import androidx.activity.viewModels
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.compose.ComposeActivity
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.ActivityMainBinding
import at.connyduck.pixelcat.db.AccountManager
import com.fxn.pix.Options
import com.fxn.pix.Pix
import com.fxn.utility.ImageQuality
import com.google.android.material.bottomnavigation.BottomNavigationView
import javax.inject.Inject
class MainActivity : BaseActivity() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
private val mainViewModel: MainViewModel by viewModels { viewModelFactory }
private lateinit var binding: ActivityMainBinding
private lateinit var mainFragmentAdapter: MainFragmentAdapter
private val onNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
return@OnNavigationItemSelectedListener when (item.itemId) {
R.id.navigation_home -> {
binding.mainViewPager.setCurrentItem(0, false)
true
}
R.id.navigation_search -> {
binding.mainViewPager.setCurrentItem(1, false)
true
}
R.id.navigation_compose -> {
val options = Options.init()
.setRequestCode(100)
.setImageQuality(ImageQuality.HIGH)
.setScreenOrientation(Options.SCREEN_ORIENTATION_PORTRAIT)
Pix.start(this, options)
false
}
R.id.navigation_notifications -> {
binding.mainViewPager.setCurrentItem(2, false)
true
}
R.id.navigation_profile -> {
binding.mainViewPager.setCurrentItem(3, false)
true
}
else -> false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.container.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.mainViewPager.layoutParams as LinearLayout.LayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
mainFragmentAdapter = MainFragmentAdapter(this)
binding.mainViewPager.adapter = mainFragmentAdapter
binding.mainViewPager.isUserInputEnabled = false
binding.navigation.setOnNavigationItemSelectedListener(onNavigationItemSelectedListener)
mainViewModel.whatever()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK && requestCode == 100) {
val returnValue =
data?.getStringArrayListExtra(Pix.IMAGE_RESULTS)
Log.e("Result", returnValue.toString())
startActivity(ComposeActivity.newIntent(this, returnValue?.firstOrNull()!!))
}
}
}

View File

@ -0,0 +1,27 @@
package at.connyduck.pixelcat.components.main
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import at.connyduck.pixelcat.components.notifications.NotificationsFragment
import at.connyduck.pixelcat.components.profile.ProfileFragment
import at.connyduck.pixelcat.components.search.SearchFragment
import at.connyduck.pixelcat.components.timeline.TimelineFragment
class MainFragmentAdapter(fragmentActivity: FragmentActivity): FragmentStateAdapter(fragmentActivity) {
override fun createFragment(position: Int): Fragment {
return when(position) {
0 -> TimelineFragment.newInstance()
1 -> SearchFragment.newInstance()
2 -> NotificationsFragment.newInstance()
3 -> ProfileFragment.newInstance()
else -> {
throw IllegalStateException()
}
}
}
override fun getItemCount() = 4
}

View File

@ -0,0 +1,30 @@
package at.connyduck.pixelcat.components.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.network.FediverseApi
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainViewModel @Inject constructor(
private val fediverseApi: FediverseApi,
private val accountManager: AccountManager
): ViewModel() {
fun whatever() {
}
init {
viewModelScope.launch {
fediverseApi.accountVerifyCredentials().fold({ account ->
accountManager.updateActiveAccount(account)
}, {
})
}
}
}

View File

@ -0,0 +1,20 @@
package at.connyduck.pixelcat.components.notifications
import androidx.fragment.app.viewModels
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.dagger.ViewModelFactory
import dagger.android.support.DaggerFragment
import javax.inject.Inject
class NotificationsFragment: DaggerFragment(R.layout.fragment_notifications) {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val notificationsViewModel: NotificationsViewModel by viewModels { viewModelFactory }
companion object {
fun newInstance() = NotificationsFragment()
}
}

View File

@ -0,0 +1,12 @@
package at.connyduck.pixelcat.components.notifications
import androidx.lifecycle.ViewModel
import javax.inject.Inject
class NotificationsViewModel @Inject constructor(
): ViewModel() {
}

View File

@ -0,0 +1,34 @@
package at.connyduck.pixelcat.components.profile
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
class GridSpacingItemDecoration(
private val spanCount: Int,
private val spacing: Int,
private val topOffset: Int
) : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view) // item position
if(position < topOffset) return
val column = (position - topOffset) % spanCount // item column
outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing)
outRect.right =
spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing)
if (position - topOffset >= spanCount) {
outRect.top = spacing // item top
}
}
}

View File

@ -0,0 +1,45 @@
package at.connyduck.pixelcat.components.profile
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.databinding.ActivityProfileBinding
class ProfileActivity: BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.root.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
binding.root.setPadding(0, top, 0, 0)
insets.consumeSystemWindowInsets()
}
if (supportFragmentManager.findFragmentById(R.id.layoutContainer) == null) {
supportFragmentManager.commit {
add(R.id.layoutContainer, ProfileFragment.newInstance(intent.getStringExtra(EXTRA_ACCOUNT_ID)))
}
}
}
companion object {
private const val EXTRA_ACCOUNT_ID = "ACCOUNT_ID"
fun newIntent(context: Context, accountId: String): Intent {
return Intent(context, ProfileActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT_ID, accountId)
}
}
}
}

View File

@ -0,0 +1,73 @@
package at.connyduck.pixelcat.components.profile
import androidx.paging.DataSource
import androidx.paging.ItemKeyedDataSource
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.model.Status
import at.connyduck.pixelcat.network.FediverseApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ProfileDataSourceFactory(
private val api: FediverseApi,
private val accountId: String?,
private val accountManager: AccountManager,
private val scope: CoroutineScope
) : DataSource.Factory<String, Status>() {
override fun create(): DataSource<String, Status> {
val source = ProfileImageDataSource(api, accountId, accountManager, scope)
return source
}
}
class ProfileImageDataSource(
private val api: FediverseApi,
private val accountId: String?,
private val accountManager: AccountManager,
private val scope: CoroutineScope
): ItemKeyedDataSource<String, Status>() {
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<Status>
) {
scope.launch(context = Dispatchers.IO) {
val id = accountId ?: accountManager.activeAccount()?.accountId!!
api.accountStatuses(
id,
limit = params.requestedLoadSize,
onlyMedia = true,
excludeReblogs = true
).fold({
callback.onResult(it)
}, {
})
}
}
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Status>) {
scope.launch(context = Dispatchers.IO) {
val id = accountId ?: accountManager.activeAccount()?.accountId!!
api.accountStatuses(
id,
maxId = params.key,
limit = params.requestedLoadSize,
onlyMedia = true,
excludeReblogs = true
).fold({
callback.onResult(it)
}, {
})
}
}
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<Status>) {
// we always load from top
}
override fun getKey(item: Status) = item.id
}

View File

@ -0,0 +1,142 @@
package at.connyduck.pixelcat.components.profile
import android.os.Bundle
import android.view.*
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.MergeAdapter
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.bottomsheet.accountselection.AccountSelectionBottomSheet
import at.connyduck.pixelcat.components.bottomsheet.menu.MenuBottomSheet
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.components.util.Success
import at.connyduck.pixelcat.components.util.extension.getDisplayWidthInPx
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.FragmentProfileBinding
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.model.Account
import at.connyduck.pixelcat.model.Relationship
import at.connyduck.pixelcat.util.arg
import at.connyduck.pixelcat.util.viewBinding
import at.connyduck.pixelcat.util.withArgs
import com.google.android.material.snackbar.Snackbar
import dagger.android.support.DaggerFragment
import javax.inject.Inject
class ProfileFragment : DaggerFragment(R.layout.fragment_profile) {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ProfileViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentProfileBinding::bind)
private var loadedAccount: Account? = null
private val headerAdapter = ProfileHeaderAdapter()
private lateinit var imageAdapter: ProfileImageAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if(activity is MainActivity) {
binding.toolbar.inflateMenu(R.menu.secondary_navigation)
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.navigation_accounts -> {
val bottomSheetDialog =
AccountSelectionBottomSheet(accountManager)
bottomSheetDialog.show(childFragmentManager, "accountsBottomSheet")
}
R.id.navigation_menu -> {
val bottomSheetDialog =
MenuBottomSheet()
bottomSheetDialog.show(childFragmentManager, "menuBottomSheet")
}
}
true
}
} else {
binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_back)
binding.toolbar.setNavigationOnClickListener {
activity?.onBackPressed()
}
}
val displayWidth = view.context.getDisplayWidthInPx()
val imageSpacing = resources.getDimensionPixelOffset(R.dimen.profile_images_spacing)
val imageSize = (displayWidth - (IMAGE_COLUMN_COUNT - 1) * imageSpacing) / IMAGE_COLUMN_COUNT
imageAdapter = ProfileImageAdapter(imageSize)
val layoutManager = GridLayoutManager(view.context, IMAGE_COLUMN_COUNT)
layoutManager.spanSizeLookup = object: GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
if(position == 0) return IMAGE_COLUMN_COUNT
return 1
}
}
binding.profileRecyclerView.layoutManager = layoutManager
binding.profileRecyclerView.adapter = MergeAdapter(headerAdapter, imageAdapter)
binding.profileRecyclerView.addItemDecoration(GridSpacingItemDecoration(IMAGE_COLUMN_COUNT, imageSpacing, 1))
viewModel.setAccountInfo(arg(ACCOUNT_ID))
viewModel.profile.observe(viewLifecycleOwner, Observer {
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> showError()
}
})
viewModel.relationship.observe(viewLifecycleOwner, Observer {
when (it) {
is Success -> onRelationshipChanged(it.data)
is Error -> showError()
}
})
viewModel.profileImages.observe(viewLifecycleOwner, Observer {
imageAdapter.submitList(it)
})
}
private fun onAccountChanged(account: Account?) {
loadedAccount = account ?: return
binding.toolbar.title = account.displayName
headerAdapter.setAccount(account, viewModel.isSelf)
}
private fun onRelationshipChanged(relation: Relationship?) {
relation?.let {
headerAdapter.setRelationship(it)
}
}
private fun showError() {
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.load(reload = true) }
.show()
}
companion object {
private const val IMAGE_COLUMN_COUNT = 3
private const val ACCOUNT_ID = "ACCOUNT_ID"
/**
* create a new ProfileFragment instance
* @param accountId the id of the profile to load, null to show the profile of the currently active user
*/
fun newInstance(accountId: String? = null) =
ProfileFragment().withArgs { putString(ACCOUNT_ID, accountId) }
}
}

View File

@ -0,0 +1,83 @@
package at.connyduck.pixelcat.components.profile
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.extension.visible
import at.connyduck.pixelcat.databinding.ItemProfileHeaderBinding
import at.connyduck.pixelcat.model.Account
import at.connyduck.pixelcat.model.Relationship
import coil.api.load
import coil.transform.RoundedCornersTransformation
import java.text.NumberFormat
class ProfileHeaderAdapter: RecyclerView.Adapter<ProfileHeaderViewHolder>() {
private var account: Account? = null
private var isSelf: Boolean = false
private var relationship: Relationship? = null
fun setAccount(account: Account, isSelf: Boolean) {
this.account = account
this.isSelf = isSelf
notifyItemChanged(0, ACCOUNT_CHANGED)
}
fun setRelationship(relationship: Relationship) {
this.relationship = relationship
notifyItemChanged(1, RELATIONSHIP_CHANGED)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileHeaderViewHolder {
val binding = ItemProfileHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ProfileHeaderViewHolder(binding)
}
override fun onBindViewHolder(holder: ProfileHeaderViewHolder, position: Int) {
// nothing to do
}
override fun onBindViewHolder(holder: ProfileHeaderViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty() || payloads.contains(ACCOUNT_CHANGED)) {
account?.let {
holder.binding.profileName.text = it.username
holder.binding.profileFollowButton.visible = !isSelf
holder.binding.profileMessageButton.visible = !isSelf
val numberFormat = NumberFormat.getNumberInstance()
holder.binding.profileImage.load(it.avatar) {
transformations(RoundedCornersTransformation(20f))
}
holder.binding.profileFollowersTextView.text = numberFormat.format(it.followersCount)
holder.binding.profileFollowingTextView.text = numberFormat.format(it.followingCount)
holder.binding.profileStatusesTextView.text = numberFormat.format(it.statusesCount)
holder.binding.profileNote.text = it.note
}
}
if (payloads.isEmpty() || payloads.contains(RELATIONSHIP_CHANGED)) {
relationship?.let {
if(it.following) {
holder.binding.profileFollowButton.setText(R.string.profile_follows_you)
} else {
holder.binding.profileFollowButton.setText(R.string.profile_action_follow)
}
holder.binding.profileFollowsYouText.visible = it.followedBy
}
}
}
override fun getItemCount() = 1
companion object {
const val ACCOUNT_CHANGED = "ACCOUNT"
const val RELATIONSHIP_CHANGED = "RELATIONSHIP"
}
}
class ProfileHeaderViewHolder(val binding: ItemProfileHeaderBinding): RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,61 @@
package at.connyduck.pixelcat.components.profile
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.extension.hide
import at.connyduck.pixelcat.components.util.extension.show
import at.connyduck.pixelcat.databinding.ItemProfileImageBinding
import at.connyduck.pixelcat.model.Attachment
import at.connyduck.pixelcat.model.Status
import coil.api.load
class ProfileImageAdapter(
private val imageSizePx: Int
): PagedListAdapter<Status, ProfileImageViewHolder>(
object: DiffUtil.ItemCallback<Status>() {
override fun areItemsTheSame(old: Status, new: Status): Boolean {
return false
}
override fun areContentsTheSame(old: Status, new: Status): Boolean {
return true
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileImageViewHolder {
val binding = ItemProfileImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.layoutParams = ViewGroup.LayoutParams(imageSizePx, imageSizePx)
return ProfileImageViewHolder(binding)
}
override fun onBindViewHolder(holder: ProfileImageViewHolder, position: Int) {
getItem(position)?.let { status ->
holder.binding.profileImageView.load(status.attachments.firstOrNull()?.previewUrl)
when {
status.attachments.size > 1 -> {
holder.binding.profileImageIcon.show()
holder.binding.profileImageIcon.setImageResource(R.drawable.ic_multiple)
}
status.attachments.first().type == Attachment.Type.VIDEO -> {
holder.binding.profileImageIcon.show()
holder.binding.profileImageIcon.setImageResource(R.drawable.ic_play)
}
else -> {
holder.binding.profileImageIcon.hide()
}
}
}
}
}
class ProfileImageViewHolder(val binding: ItemProfileImageBinding): RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,89 @@
package at.connyduck.pixelcat.components.profile
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagedList
import at.connyduck.pixelcat.components.util.Error
import at.connyduck.pixelcat.components.util.Success
import at.connyduck.pixelcat.components.util.UiState
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.model.Account
import at.connyduck.pixelcat.model.Relationship
import at.connyduck.pixelcat.model.Status
import at.connyduck.pixelcat.network.FediverseApi
import com.bumptech.glide.util.Executors
import kotlinx.coroutines.launch
import javax.inject.Inject
class ProfileViewModel @Inject constructor(
private val fediverseApi: FediverseApi,
private val accountManager: AccountManager
): ViewModel() {
val profile = MutableLiveData<UiState<Account>>()
val relationship = MutableLiveData<UiState<Relationship>>()
val profileImages = MutableLiveData<PagedList<Status>>()
val isSelf: Boolean
get() = accountId == null
private var accountId: String? = null
fun load(reload: Boolean = false) {
loadAccount(reload)
if (!isSelf) {
loadRelationship(reload)
}
loadImages(reload)
}
fun setAccountInfo(accountId: String?) {
this@ProfileViewModel.accountId = accountId
load(false)
}
private fun loadAccount(reload: Boolean = false) {
if (profile.value == null || reload) {
viewModelScope.launch {
fediverseApi.account(getAccountId()).fold({
profile.value = Success(it)
}, {
profile.value = Error(cause = it)
})
}
}
}
private fun loadRelationship(reload: Boolean = false) {
if (relationship.value == null || reload) {
viewModelScope.launch {
fediverseApi.relationships(listOf(getAccountId())).fold({
relationship.value = Success(it.first())
}, {
relationship.value = Error(cause = it)
})
}
}
}
private fun loadImages(reload: Boolean = false) {
if(profileImages.value == null || reload) {
profileImages.value = PagedList.Builder(
ProfileImageDataSource(
fediverseApi,
accountId,
accountManager,
viewModelScope
), 20
).setNotifyExecutor(Executors.mainThreadExecutor())
.setFetchExecutor(java.util.concurrent.Executors.newSingleThreadExecutor())
.build()
}
}
private suspend fun getAccountId(): String {
return accountId ?: accountManager.activeAccount()?.accountId!!
}
}

View File

@ -0,0 +1,20 @@
package at.connyduck.pixelcat.components.search
import androidx.fragment.app.viewModels
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.dagger.ViewModelFactory
import dagger.android.support.DaggerFragment
import javax.inject.Inject
class SearchFragment: DaggerFragment(R.layout.fragment_search) {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val searchViewModel: SearchViewModel by viewModels { viewModelFactory }
companion object {
fun newInstance() = SearchFragment()
}
}

View File

@ -0,0 +1,12 @@
package at.connyduck.pixelcat.components.search
import androidx.lifecycle.ViewModel
import javax.inject.Inject
class SearchViewModel @Inject constructor(
): ViewModel() {
}

View File

@ -0,0 +1,68 @@
package at.connyduck.pixelcat.components.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.*
import at.connyduck.pixelcat.R
import javax.inject.Inject
class AppSettings @Inject constructor (
private val sharedPrefs: SharedPreferences,
private val context: Context
) {
@StyleRes
fun getAppColorStyle(): Int {
val appColorPref = sharedPrefs.getString(
context.getString(R.string.key_pref_app_color),
context.getString(R.string.key_pref_app_color_default)
)
return when (appColorPref) {
context.getString(R.string.key_pref_app_color_warm) -> R.style.Warm
context.getString(R.string.key_pref_app_color_cold) -> R.style.Cold
else -> throw IllegalStateException()
}
}
@AppCompatDelegate.NightMode
fun getNightMode(): Int {
val nightModePref = sharedPrefs.getString(
context.getString(R.string.key_pref_night_mode),
context.getString(R.string.key_pref_night_mode_default)
)
return when (nightModePref) {
context.getString(R.string.key_pref_night_mode_off) -> MODE_NIGHT_NO
context.getString(R.string.key_pref_night_mode_on) -> MODE_NIGHT_YES
context.getString(R.string.key_pref_night_mode_auto) -> MODE_NIGHT_AUTO_TIME
context.getString(R.string.key_pref_night_mode_follow_system) -> MODE_NIGHT_FOLLOW_SYSTEM
else -> throw IllegalStateException()
}
}
fun isBlackNightMode(): Boolean {
return sharedPrefs.getBoolean(
context.getString(R.string.key_pref_black_night_mode),
context.resources.getBoolean(R.bool.pref_title_black_night_mode_default)
)
}
fun useSystemFont(): Boolean {
return sharedPrefs.getBoolean(
context.getString(R.string.key_pref_system_font),
context.resources.getBoolean(R.bool.pref_title_system_font_default)
)
}
}
private fun SharedPreferences.getNonNullString(key: String, default: String): String {
return getString(key, default) ?: default
}

View File

@ -0,0 +1,105 @@
package at.connyduck.pixelcat.components.settings
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceFragmentCompat
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.databinding.ActivitySettingsBinding
import at.connyduck.pixelcat.util.viewBinding
import javax.inject.Inject
class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var preferences: SharedPreferences
private val binding by viewBinding(ActivitySettingsBinding::inflate)
private var restartActivitiesOnExit = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.settingsContainer.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.settingsToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment())
.commit()
binding.settingsToolbar.setNavigationOnClickListener {
onBackPressed()
}
preferences.registerOnSharedPreferenceChangeListener(this)
restartActivitiesOnExit = intent.getBooleanExtra(EXTRA_RESTART_ACTIVITIES, false)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when(key) {
getString(R.string.key_pref_app_color) -> restartCurrentActivity()
getString(R.string.key_pref_night_mode) -> AppCompatDelegate.setDefaultNightMode(appSettings.getNightMode())
}
}
private fun restartCurrentActivity() {
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra(EXTRA_RESTART_ACTIVITIES, restartActivitiesOnExit)
startActivity(intent)
finish()
overridePendingTransition(R.anim.fade_in, R.anim.fade_out)
}
override fun onBackPressed() {
val parentActivityName = intent.getStringExtra(EXTRA_PARENT_ACTIVITY)
if(restartActivitiesOnExit && parentActivityName != null) {
val restartIntent = Intent()
restartIntent.component = ComponentName(this, intent.getStringExtra(EXTRA_PARENT_ACTIVITY)!!)
restartIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(restartIntent)
} else {
super.onBackPressed()
}
}
override fun onDestroy() {
super.onDestroy()
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
}
}
companion object {
private const val EXTRA_PARENT_ACTIVITY = "parent"
private const val EXTRA_RESTART_ACTIVITIES = "restart"
fun newIntent(activity: Activity): Intent {
return Intent(activity, SettingsActivity::class.java).apply {
putExtra(EXTRA_PARENT_ACTIVITY, activity.componentName.className)
}
}
}
}

View File

@ -0,0 +1,35 @@
package at.connyduck.pixelcat.components.splash
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import at.connyduck.pixelcat.components.login.LoginActivity
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.db.AccountManager
import dagger.android.support.DaggerAppCompatActivity
import kotlinx.coroutines.launch
import javax.inject.Inject
class SplashActivity: DaggerAppCompatActivity() {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val intent = if (accountManager.activeAccount() != null) {
Intent(
this@SplashActivity,
MainActivity::class.java
) //TODO don't create intents here
} else {
Intent(this@SplashActivity, LoginActivity::class.java)
}
startActivity(intent)
finish()
}
}
}

View File

@ -0,0 +1,92 @@
package at.connyduck.pixelcat.components.timeline
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.util.getColorForAttr
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.FragmentTimelineBinding
import at.connyduck.pixelcat.db.entitity.StatusEntity
import at.connyduck.pixelcat.util.viewBinding
import dagger.android.support.DaggerFragment
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class TimelineFragment: DaggerFragment(R.layout.fragment_timeline), TimeLineActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTimelineBinding::bind)
@ExperimentalPagingApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.timelineSwipeRefresh.setColorSchemeColors(
view.context.getColorForAttr(R.attr.pixelcat_gradient_color_start),
view.context.getColorForAttr(R.attr.pixelcat_gradient_color_end)
)
binding.toolbar.setNavigationOnClickListener {
binding.timelineRecyclerView.scrollToPosition(0)
}
/* binding.timelineContainer.setOnApplyWindowInsetsListener { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = binding.toolbar.layoutParams as AppBarLayout.LayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}*/
val adapter = TimelineListAdapter(this)
binding.timelineRecyclerView.adapter = adapter
(binding.timelineRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.timelineRecyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
binding.timelineSwipeRefresh.setOnRefreshListener {
adapter.refresh()
}
adapter.addDataRefreshListener {
binding.timelineSwipeRefresh.isRefreshing = false
}
//viewModel.posts.observe(viewLifecycleOwner, Observer { t -> adapter.submitList(t) })
}
companion object {
fun newInstance() = TimelineFragment()
}
override fun onFavorite(post: StatusEntity) {
viewModel.onFavorite(post)
}
override fun onBoost(post: StatusEntity) {
TODO("Not yet implemented")
}
override fun onReply(status: StatusEntity) {
TODO("Not yet implemented")
}
}

View File

@ -0,0 +1,34 @@
package at.connyduck.pixelcat.components.timeline
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.databinding.ItemTimelineImageBinding
import at.connyduck.pixelcat.model.Attachment
import coil.api.load
class TimelineImageAdapter: RecyclerView.Adapter<TimelineImageViewHolder>() {
var images: List<Attachment> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimelineImageViewHolder {
val binding = ItemTimelineImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TimelineImageViewHolder(binding)
}
override fun getItemCount() = images.size
override fun onBindViewHolder(holder: TimelineImageViewHolder, position: Int) {
holder.binding.timelineImageView.load(images[position].previewUrl)
}
}
class TimelineImageViewHolder(val binding: ItemTimelineImageBinding): RecyclerView.ViewHolder(binding.root)

View File

@ -0,0 +1,104 @@
package at.connyduck.pixelcat.components.timeline
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.text.parseAsHtml
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.pixelcat.components.profile.ProfileActivity
import at.connyduck.pixelcat.components.util.extension.visible
import at.connyduck.pixelcat.databinding.ItemStatusBinding
import at.connyduck.pixelcat.db.entitity.StatusEntity
import coil.api.load
import coil.transform.RoundedCornersTransformation
import java.text.SimpleDateFormat
interface TimeLineActionListener {
fun onFavorite(post: StatusEntity)
fun onBoost(post: StatusEntity)
fun onReply(status: StatusEntity)
}
object TimelineDiffUtil: DiffUtil.ItemCallback<StatusEntity>() {
override fun areItemsTheSame(oldItem: StatusEntity, newItem: StatusEntity): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: StatusEntity, newItem: StatusEntity): Boolean {
return oldItem == newItem
}
}
class TimelineListAdapter(
private val listener: TimeLineActionListener
) : PagingDataAdapter<StatusEntity, TimelineViewHolder>(TimelineDiffUtil) {
private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimelineViewHolder {
val binding = ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TimelineViewHolder(binding)
}
override fun onBindViewHolder(holder: TimelineViewHolder, position: Int) {
getItem(position)?.let { status ->
// TODO order the stuff here
(holder.binding.postImages.adapter as TimelineImageAdapter).images = status.attachments
holder.binding.postAvatar.load(status.account.avatar) {
transformations(RoundedCornersTransformation(25f))
}
holder.binding.postAvatar.setOnClickListener {
holder.binding.root.context.startActivity(ProfileActivity.newIntent(holder.binding.root.context, status.account.id))
}
holder.binding.postDisplayName.text = status.account.displayName
holder.binding.postName.text = "@${status.account.username}"
holder.binding.postLikeButton.isChecked = status.favourited
holder.binding.postLikeButton.setEventListener { button, buttonState ->
listener.onFavorite(status)
true
}
holder.binding.postBoostButton.setEventListener { button, buttonState ->
listener.onBoost(status)
true
}
holder.binding.postReplyButton.setOnClickListener {
listener.onReply(status)
}
holder.binding.postIndicator.visible = status.attachments.size > 1
holder.binding.postImages.visible = status.attachments.isNotEmpty()
holder.binding.postDescription.text = status.content.parseAsHtml().trim()
holder.binding.postDate.text = dateTimeFormatter.format(status.createdAt)
}
}
}
class TimelineViewHolder(val binding: ItemStatusBinding): RecyclerView.ViewHolder(binding.root) {
init {
binding.postImages.adapter = TimelineImageAdapter()
binding.postIndicator.setViewPager(binding.postImages)
(binding.postImages.adapter as TimelineImageAdapter).registerAdapterDataObserver(binding.postIndicator.adapterDataObserver)
// val snapHelper = PagerSnapHelper()
// snapHelper.attachToRecyclerView(binding.postImages)
}
}

View File

@ -0,0 +1,55 @@
package at.connyduck.pixelcat.components.timeline
import android.util.Log
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import at.connyduck.pixelcat.db.AppDatabase
import at.connyduck.pixelcat.db.entitity.StatusEntity
import at.connyduck.pixelcat.db.entitity.toEntity
import at.connyduck.pixelcat.network.FediverseApi
@ExperimentalPagingApi
class TimelineRemoteMediator(
private val accountId: Long,
private val api: FediverseApi,
private val db: AppDatabase
): RemoteMediator<Int, StatusEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, StatusEntity>
): MediatorResult {
val apiCall = when (loadType) {
LoadType.REFRESH -> {
api.homeTimeline(limit = state.config.initialLoadSize)
}
LoadType.PREPEND -> {
return MediatorResult.Success(true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id
Log.w("TimelineRemoteMediator", "anchorPosition: ${state.anchorPosition} maxId: $maxId")
api.homeTimeline(maxId = maxId, limit = state.config.pageSize)
}
}
return apiCall.fold({ statusResult ->
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.statusDao().clearAll(accountId)
}
db.statusDao().insertOrReplace(statusResult.map { it.toEntity(accountId) })
}
MediatorResult.Success(endOfPaginationReached = statusResult.isEmpty())
}, {
MediatorResult.Error(it)
})
}
override suspend fun initialize() = InitializeAction.SKIP_INITIAL_REFRESH
}

View File

@ -0,0 +1,45 @@
package at.connyduck.pixelcat.components.timeline
import androidx.lifecycle.*
import androidx.paging.*
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.db.AppDatabase
import at.connyduck.pixelcat.db.entitity.AccountEntity
import at.connyduck.pixelcat.db.entitity.StatusEntity
import at.connyduck.pixelcat.network.FediverseApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
class TimelineViewModel @Inject constructor(
// private val repository: TimelineRepo,
private val accountManager: AccountManager,
private val db: AppDatabase,
fediverseApi: FediverseApi
): ViewModel() {
private val accountId = MutableLiveData<Long>()
init {
viewModelScope.launch {
val currentAccountId = accountManager.activeAccount()?.id!!
accountId.postValue(currentAccountId)
}
}
@ExperimentalPagingApi
val flow = Pager(
// Configure how data is loaded by passing additional properties to
// PagingConfig, such as prefetchDistance.
config = PagingConfig(pageSize = 10, enablePlaceholders = false),
remoteMediator = TimelineRemoteMediator(0, fediverseApi, db),
pagingSourceFactory = { db.statusDao().statuses(0) }
).flow.cachedIn(viewModelScope)
fun onFavorite(status: StatusEntity) {
viewModelScope.launch {
// repository.onFavorite(status, accountManager.activeAccount()?.id!!)
}
}
}

View File

@ -0,0 +1,13 @@
package at.connyduck.pixelcat.components.util
sealed class UiState<T>(open val data: T?)
class Loading<T> (override val data: T? = null) : UiState<T>(data)
class Success<T> (override val data: T? = null) : UiState<T>(data)
class Error<T> (override val data: T? = null,
val errorMessage: String? = null,
var consumed: Boolean = false,
val cause: Throwable? = null
): UiState<T>(data)

View File

@ -0,0 +1,15 @@
package at.connyduck.pixelcat.components.util
import android.content.Context
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
@ColorInt
fun Context.getColorForAttr(@AttrRes attr: Int): Int {
val value = TypedValue()
if(theme.resolveAttribute(attr, value, true)) {
return value.data
}
throw IllegalStateException("Attribute not found")
}

View File

@ -0,0 +1,25 @@
package at.connyduck.pixelcat.components.util.extension
import android.content.Context
import android.util.DisplayMetrics
import android.util.TypedValue
import android.view.WindowManager
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
fun Context.getDisplayWidthInPx(): Int {
val metrics = DisplayMetrics()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
windowManager.defaultDisplay.getMetrics(metrics)
return metrics.widthPixels
}
@ColorInt
fun Context.getColorForAttr(@AttrRes attr: Int): Int {
val value = TypedValue()
return if (this.theme.resolveAttribute(attr, value, true)) {
value.data
} else {
throw IllegalArgumentException()
}
}

View File

@ -0,0 +1,17 @@
package at.connyduck.pixelcat.components.util.extension
import android.view.View
fun View.show() {
this.visibility = View.VISIBLE
}
fun View.hide() {
this.visibility = View.GONE
}
var View.visible
get() = visibility == View.VISIBLE
set(value) {
visibility = if(value) View.VISIBLE else View.GONE
}

View File

@ -0,0 +1,14 @@
package at.connyduck.pixelcat.config
object Config {
const val website = "https://pixelcat.app"
const val oAuthScheme = "pixelcat"
const val oAuthHost = "oauth"
const val oAuthRedirect = "$oAuthScheme://$oAuthHost"
const val oAuthScopes = "read write follow"
val domainExceptions = arrayOf("gab.com", "gab.ai", "gabfed.com")
}

View File

@ -0,0 +1,42 @@
package at.connyduck.pixelcat.dagger
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.components.about.AboutActivity
import at.connyduck.pixelcat.components.about.licenses.LicenseActivity
import at.connyduck.pixelcat.components.compose.ComposeActivity
import at.connyduck.pixelcat.components.login.LoginActivity
import at.connyduck.pixelcat.components.profile.ProfileActivity
import at.connyduck.pixelcat.components.settings.SettingsActivity
import at.connyduck.pixelcat.components.splash.SplashActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class ActivityModule {
//TODO order stuff here
@ContributesAndroidInjector(modules = [FragmentModule::class])
abstract fun contributesMainActivity(): MainActivity
@ContributesAndroidInjector
abstract fun contributesLoginActivity(): LoginActivity
@ContributesAndroidInjector(modules = [FragmentModule::class])
abstract fun contributesSettingsActivity(): SettingsActivity
@ContributesAndroidInjector
abstract fun contributesLicenseActivity(): LicenseActivity
@ContributesAndroidInjector
abstract fun contributesAboutActivity(): AboutActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
@ContributesAndroidInjector(modules = [FragmentModule::class])
abstract fun contributesProfileActivity(): ProfileActivity
@ContributesAndroidInjector
abstract fun contributesComposeActivity(): ComposeActivity
}

View File

@ -0,0 +1,30 @@
package at.connyduck.pixelcat.dagger
import at.connyduck.pixelcat.PixelcatApplication
import dagger.BindsInstance
import dagger.Component
import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector
import javax.inject.Singleton
@Singleton
@Component(modules = [
AppModule::class,
NetworkModule::class,
AndroidInjectionModule::class,
ActivityModule::class,
ViewModelModule::class,
ServiceModule::class
])
interface AppComponent : AndroidInjector<PixelcatApplication> {
@Component.Builder
interface Builder {
@BindsInstance
fun application(app: PixelcatApplication): Builder
fun build(): AppComponent
}
override fun inject(app: PixelcatApplication)
}

View File

@ -0,0 +1,46 @@
package at.connyduck.pixelcat.dagger
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.room.Room
import at.connyduck.pixelcat.PixelcatApplication
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.db.AppDatabase
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@Module
class AppModule {
@Provides
fun providesApp(app: PixelcatApplication): Application = app
@Provides
fun providesContext(app: Application): Context = app
@Provides
fun providesSharedPreferences(app: Application): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(app)
}
@Provides
@Singleton
fun providesDatabase(app: PixelcatApplication): AppDatabase {
return Room
.databaseBuilder(app, AppDatabase::class.java, "pixelcat.db")
.build()
}
@Provides
@Singleton
fun providesAccountManager(db: AppDatabase): AccountManager {
return AccountManager(db)
}
}

View File

@ -0,0 +1,25 @@
package at.connyduck.pixelcat.dagger
import at.connyduck.pixelcat.components.notifications.NotificationsFragment
import at.connyduck.pixelcat.components.profile.ProfileFragment
import at.connyduck.pixelcat.components.search.SearchFragment
import at.connyduck.pixelcat.components.timeline.TimelineFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class FragmentModule {
@ContributesAndroidInjector
abstract fun timelineFragment(): TimelineFragment
@ContributesAndroidInjector
abstract fun searchFragment(): SearchFragment
@ContributesAndroidInjector
abstract fun notificationsFragment(): NotificationsFragment
@ContributesAndroidInjector
abstract fun profileFragment(): ProfileFragment
}

View File

@ -0,0 +1,70 @@
package at.connyduck.pixelcat.dagger
import at.connyduck.pixelcat.BuildConfig
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.network.FediverseApi
import at.connyduck.pixelcat.network.InstanceSwitchAuthInterceptor
import at.connyduck.pixelcat.network.RefreshTokenAuthenticator
import at.connyduck.pixelcat.network.UserAgentInterceptor
import at.connyduck.pixelcat.network.calladapter.NetworkResponseAdapterFactory
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import dagger.Module
import dagger.Provides
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
class NetworkModule {
@Provides
@Singleton
fun providesOkHttpClient(accountManager: AccountManager): OkHttpClient {
val okHttpClientBuilder = OkHttpClient.Builder()
.addInterceptor(UserAgentInterceptor())
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.authenticator(RefreshTokenAuthenticator(accountManager))
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
if(BuildConfig.DEBUG) {
okHttpClientBuilder.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
})
}
return okHttpClientBuilder.build()
}
@Provides
@Singleton
fun providesMoshi(): Moshi {
return Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.build()
}
@Provides
@Singleton
fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit {
return Retrofit.Builder()
.baseUrl("https://" + FediverseApi.PLACEHOLDER_DOMAIN)
.client(httpClient)
.addCallAdapterFactory(NetworkResponseAdapterFactory())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun providesApi(retrofit: Retrofit): FediverseApi = retrofit.create()
}

View File

@ -0,0 +1,14 @@
package at.connyduck.pixelcat.dagger
import at.connyduck.pixelcat.components.compose.SendStatusService
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class ServiceModule {
@ContributesAndroidInjector
abstract fun contributesSendStatusService(): SendStatusService
}

View File

@ -0,0 +1,76 @@
// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
package at.connyduck.pixelcat.dagger
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.connyduck.pixelcat.components.compose.ComposeViewModel
import at.connyduck.pixelcat.components.login.LoginViewModel
import at.connyduck.pixelcat.components.main.MainViewModel
import at.connyduck.pixelcat.components.notifications.NotificationsViewModel
import at.connyduck.pixelcat.components.profile.ProfileViewModel
import at.connyduck.pixelcat.components.search.SearchViewModel
import at.connyduck.pixelcat.components.timeline.TimelineViewModel
import dagger.Binds
import dagger.MapKey
import dagger.Module
import dagger.multibindings.IntoMap
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.reflect.KClass
@Singleton
class ViewModelFactory @Inject constructor(private val viewModels: MutableMap<Class<out ViewModel>, Provider<ViewModel>>) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = viewModels[modelClass]?.get() as T
}
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
@Retention(AnnotationRetention.RUNTIME)
@MapKey
internal annotation class ViewModelKey(val value: KClass<out ViewModel>)
@Module
abstract class ViewModelModule {
@Binds
internal abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
@Binds
@IntoMap
@ViewModelKey(LoginViewModel::class)
internal abstract fun loginViewModel(viewModel: LoginViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(MainViewModel::class)
internal abstract fun mainViewModel(viewModel: MainViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(TimelineViewModel::class)
internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(SearchViewModel::class)
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(NotificationsViewModel::class)
internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ProfileViewModel::class)
internal abstract fun profileViewModel(viewModel: ProfileViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ComposeViewModel::class)
internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel
//Add more ViewModels here
}

View File

@ -0,0 +1,18 @@
package at.connyduck.pixelcat.db
import androidx.room.*
import at.connyduck.pixelcat.db.entitity.AccountEntity
@Dao
interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrReplace(account: AccountEntity): Long
@Delete
suspend fun delete(account: AccountEntity)
@Query("SELECT * FROM AccountEntity ORDER BY id ASC")
suspend fun loadAll(): List<AccountEntity>
}

View File

@ -0,0 +1,191 @@
package at.connyduck.pixelcat.db
import android.util.Log
import at.connyduck.pixelcat.db.entitity.AccountAuthData
import at.connyduck.pixelcat.db.entitity.AccountEntity
import at.connyduck.pixelcat.model.Account
/**
* This class caches the account database and handles all account related operations
* @author ConnyDuck
*/
//TODO check if the comments are up to date
private const val TAG = "AccountManager"
class AccountManager(db: AppDatabase) {
private var activeAccount: AccountEntity? = null
private var accounts: MutableList<AccountEntity> = mutableListOf()
private val accountDao: AccountDao = db.accountDao()
suspend fun activeAccount(): AccountEntity? {
if(activeAccount == null) {
accounts = accountDao.loadAll().toMutableList()
activeAccount = accounts.find { acc ->
acc.isActive
}
}
return activeAccount
}
/**
* Adds a new empty account and makes it the active account.
* More account information has to be added later with [updateActiveAccount]
* or the account wont be saved to the database.
* @param accessToken the access token for the new account
* @param domain the domain of the accounts Mastodon instance
*/
suspend fun addAccount(domain: String, authData: AccountAuthData) {
activeAccount?.let {
it.isActive = false
Log.d(TAG, "addAccount: saving account with id " + it.id)
accountDao.insertOrReplace(it)
}
val maxAccountId = accounts.maxBy { it.id }?.id ?: 0
val newAccountId = maxAccountId + 1
activeAccount = AccountEntity(
id = newAccountId,
domain = domain,
auth = authData,
isActive = true
)
}
/**
* Saves an already known account to the database.
* New accounts must be created with [addAccount]
* @param account the account to save
*/
suspend fun saveAccount(account: AccountEntity) {
if (account.id != 0L) {
Log.d(TAG, "saveAccount: saving account with id " + account.id)
accountDao.insertOrReplace(account)
}
}
/**
* Logs the current account out by deleting all data of the account.
* @return the new active account, or null if no other account was found
*/
suspend fun logActiveAccountOut(): AccountEntity? {
if (activeAccount == null) {
return null
} else {
accounts.remove(activeAccount!!)
accountDao.delete(activeAccount!!)
if (accounts.size > 0) {
accounts[0].isActive = true
activeAccount = accounts[0]
Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id)
accountDao.insertOrReplace(accounts[0])
} else {
activeAccount = null
}
return activeAccount
}
}
/**
* updates the current account with new information from the mastodon api
* and saves it in the database
* @param account the [Account] object returned from the api
*/
suspend fun updateActiveAccount(account: Account) {
activeAccount?.let {
it.accountId = account.id
it.username = account.username
it.displayName = account.name
it.profilePictureUrl = account.avatar
// it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC
it.defaultMediaSensitivity = account.source?.sensitive ?: false
// it.emojis = account.emojis ?: emptyList()
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
it.id = accountDao.insertOrReplace(it)
val accountIndex = accounts.indexOf(it)
if (accountIndex != -1) {
//in case the user was already logged in with this account, remove the old information
accounts.removeAt(accountIndex)
accounts.add(accountIndex, it)
} else {
accounts.add(it)
}
}
}
/**
* changes the active account
* @param accountId the database id of the new active account
*/
suspend fun setActiveAccount(accountId: Long) {
activeAccount?.let {
Log.d(TAG, "setActiveAccount: saving account with id " + it.id)
it.isActive = false
saveAccount(it)
}
activeAccount = accounts.find { acc ->
acc.id == accountId
}
activeAccount?.let {
it.isActive = true
accountDao.insertOrReplace(it)
}
}
/**
* @return an immutable list of all accounts in the database with the active account first
*/
suspend fun getAllAccounts(): List<AccountEntity> {
// TODO only load accounts on demand
accounts = accountDao.loadAll().toMutableList()
activeAccount = accounts.find { acc ->
acc.isActive
}
return accounts.toList()
}
/**
* @return true if at least one account has notifications enabled
*/
fun areNotificationsEnabled(): Boolean {
return accounts.any { it.notificationsEnabled }
}
/**
* Finds an account by its database id
* @param accountId the id of the account
* @return the requested account or null if it was not found
*/
fun getAccountById(accountId: Long): AccountEntity? {
return accounts.find { acc ->
acc.id == accountId
}
}
}

View File

@ -0,0 +1,16 @@
package at.connyduck.pixelcat.db
import androidx.room.Database
import androidx.room.RoomDatabase
import at.connyduck.pixelcat.db.entitity.AccountEntity
import at.connyduck.pixelcat.db.entitity.StatusEntity
@Database(entities = [AccountEntity::class, StatusEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun statusDao(): TimelineDao
}

View File

@ -0,0 +1,57 @@
package at.connyduck.pixelcat.db
import androidx.room.TypeConverter
import at.connyduck.pixelcat.model.Attachment
import at.connyduck.pixelcat.model.Status
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.util.*
class Converters {
private val moshi = Moshi.Builder().build()
@TypeConverter
fun visibilityToInt(visibility: Status.Visibility): String {
return visibility.name
}
@TypeConverter
fun stringToVisibility(visibility: String): Status.Visibility {
return Status.Visibility.valueOf(visibility)
}
@TypeConverter
fun attachmentListToJson(attachmentList: List<Attachment>?): String {
val type = Types.newParameterizedType(
List::class.java,
Attachment::class.java
)
return moshi.adapter<List<Attachment>>(type).toJson(attachmentList)
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
if(attachmentListJson == null) {
return null
}
val type = Types.newParameterizedType(
List::class.java,
Attachment::class.java
)
return moshi.adapter<List<Attachment>>(type).fromJson(attachmentListJson)
}
@TypeConverter
fun dateToLong(date: Date): Long {
return date.time
}
@TypeConverter
fun longToDate(date: Long): Date {
return Date(date)
}
}

View File

@ -0,0 +1,25 @@
package at.connyduck.pixelcat.db
import androidx.paging.PagingSource
import androidx.room.*
import at.connyduck.pixelcat.db.entitity.StatusEntity
@Dao
interface TimelineDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrReplace(status: StatusEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrReplace(statuses: List<StatusEntity>)
@Delete
suspend fun delete(status: StatusEntity)
@Query("SELECT * FROM StatusEntity WHERE accountId = :accountId ORDER BY LENGTH(id) DESC, id DESC")
fun statuses(accountId: Long): PagingSource<Int, StatusEntity>
@Query("DELETE FROM StatusEntity WHERE accountId = :accountId")
suspend fun clearAll(accountId: Long)
}

View File

@ -0,0 +1,65 @@
package at.connyduck.pixelcat.db.entitity
import androidx.room.*
@Entity(indices = [Index(value = ["domain", "accountId"],
unique = true)])
//@TypeConverters(Converters::class)
data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
val domain: String,
@Embedded(prefix = "auth_") var auth: AccountAuthData,
var isActive: Boolean,
var accountId: String = "",
var username: String = "",
var displayName: String = "",
var profilePictureUrl: String = "",
var notificationsEnabled: Boolean = true,
var notificationsMentioned: Boolean = true,
var notificationsFollowed: Boolean = true,
var notificationsReblogged: Boolean = true,
var notificationsFavorited: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,
var defaultMediaSensitivity: Boolean = false,
var alwaysShowSensitiveMedia: Boolean = false,
var mediaPreviewEnabled: Boolean = true,
var lastNotificationId: String = "0",
var activeNotifications: String = "[]",
var notificationsFilter: String = "[]") {
val identifier: String
get() = "$domain:$accountId"
val fullName: String
get() = "@$username@$domain"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AccountEntity
if (id == other.id) return true
if (domain == other.domain && accountId == other.accountId) return true
return false
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + domain.hashCode()
result = 31 * result + accountId.hashCode()
return result
}
}
data class AccountAuthData(
val accessToken: String,
val refreshToken: String?,
val tokenExpiresAt: Long,
val clientId: String,
val clientSecret: String
)

View File

@ -0,0 +1,27 @@
package at.connyduck.pixelcat.db.entitity
import androidx.room.Entity
import at.connyduck.pixelcat.model.Account
@Entity(
primaryKeys = ["serverId", "timelineUserId"]
)
data class TimelineAccountEntity(
val serverId: Long,
val id: String,
val localUsername: String,
val username: String,
val displayName: String,
val url: String,
val avatar: String
)
fun Account.toEntity(serverId: Long) = TimelineAccountEntity(
serverId = serverId,
id = id,
localUsername = localUsername,
username = username,
displayName = displayName,
url = url,
avatar = avatar
)

View File

@ -0,0 +1,54 @@
package at.connyduck.pixelcat.db.entitity
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
import at.connyduck.pixelcat.db.Converters
import at.connyduck.pixelcat.model.Attachment
import at.connyduck.pixelcat.model.Status
import java.util.*
@Entity(
primaryKeys = ["accountId", "id"]
)
@TypeConverters(Converters::class)
data class StatusEntity(
val accountId: Long,
val id: String,
val url: String?, // not present if it's reblog
@Embedded(prefix = "a_") val account: TimelineAccountEntity,
val content: String,
val createdAt: Date,
val reblogsCount: Int,
val favouritesCount: Int,
val reblogged: Boolean,
val favourited: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val visibility: Status.Visibility,
val attachments: List<Attachment>,
val mediaPosition: Int,
val mediaVisible: Boolean
)
fun Status.toEntity(accountId: Long, mediaPosition: Int = 0, mediaVisible: Boolean = this.sensitive) = StatusEntity(
accountId = accountId,
id = id,
url = actionableStatus.url,
account = actionableStatus.account.toEntity(accountId),
content = actionableStatus.content,
createdAt = actionableStatus.createdAt,
reblogsCount = actionableStatus.reblogsCount,
favouritesCount = actionableStatus.favouritesCount,
reblogged = actionableStatus.reblogged,
favourited = actionableStatus.favourited,
sensitive = actionableStatus.sensitive,
spoilerText = actionableStatus.spoilerText,
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments,
mediaPosition = mediaPosition,
mediaVisible = mediaVisible
)

View File

@ -0,0 +1,12 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccessToken(
@Json(name = "access_token") val accessToken: String,
@Json(name = "refresh_token") val refreshToken: String?,
@Json(name = "expires_in") val expiresIn: Long?,
@Json(name = "created_at") val createdAt: Long?
)

View File

@ -0,0 +1,73 @@
package at.connyduck.pixelcat.model
import android.os.Parcel
import android.os.Parcelable
import android.text.Spanned
import androidx.core.text.HtmlCompat
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parceler
import kotlinx.android.parcel.Parcelize
import java.util.*
@JsonClass(generateAdapter = true)
data class Account(
val id: String,
@Json(name = "username") val localUsername: String,
@Json(name = "acct") val username: String,
@Json(name = "display_name") val displayName: String,
val note: String,
val url: String,
val avatar: String,
val header: String,
val locked: Boolean = false,
@Json(name = "followers_count") val followersCount: Int,
@Json(name = "following_count") val followingCount: Int,
@Json(name = "statuses_count") val statusesCount: Int,
val source: AccountSource?,
val bot: Boolean,
// val emojis: List<Emoji>, // nullable for backward compatibility
val fields: List<Field>?, //nullable for backward compatibility
val moved: Account?
) {
val name: String
get() = if (displayName.isEmpty()) {
localUsername
} else displayName
fun isRemote(): Boolean = this.username != this.localUsername
}
@JsonClass(generateAdapter = true)
@Parcelize
data class AccountSource(
// val privacy: Status.Visibility,
val sensitive: Boolean,
val note: String,
val fields: List<StringField>?
): Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class Field (
val name: String,
// val value: @WriteWith<SpannedParceler>() Spanned,
@Json(name = "verified_at") val verifiedAt: Date?
): Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class StringField (
val name: String,
val value: String
): Parcelable
object SpannedParceler : Parceler<Spanned> {
override fun create(parcel: Parcel): Spanned = HtmlCompat.fromHtml(parcel.readString() ?: "", HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_PARAGRAPH)
override fun Spanned.write(parcel: Parcel, flags: Int) {
parcel.writeString(HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
}
}

View File

@ -0,0 +1,10 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AppCredentials(
@Json(name = "client_id") val clientId: String,
@Json(name = "client_secret") val clientSecret: String
)

View File

@ -0,0 +1,68 @@
package at.connyduck.pixelcat.model
import android.os.Parcelable
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parcelize
@JsonClass(generateAdapter = true)
@Parcelize
data class Attachment(
val id: String,
val url: String,
@Json(name = "preview_url") val previewUrl: String,
val meta: MetaData?,
val type: Type,
val description: String?
) : Parcelable {
enum class Type {
@Json(name = "image")
IMAGE,
@Json(name = "gifv")
GIFV,
@Json(name = "video")
VIDEO,
@Json(name = "audio")
AUDIO,
@Json(name = "unknown")
UNKNOWN
}
/*class MediaTypeDeserializer : JsonDeserializer<Type> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type {
return when (json.toString()) {
"\"image\"" -> Type.IMAGE
"\"gifv\"" -> Type.GIFV
"\"video\"" -> Type.VIDEO
"\"audio\"" -> Type.AUDIO
else -> Type.UNKNOWN
}
}
}*/
/**
* The meta data of an [Attachment].
*/
@JsonClass(generateAdapter = true)
@Parcelize
data class MetaData (
val focus: Focus?,
val duration: Float?
) : Parcelable
/**
* The Focus entity, used to specify the focal point of an image.
*
* See here for more details what the x and y mean:
* https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
*/
@JsonClass(generateAdapter = true)
@Parcelize
data class Focus (
val x: Float,
val y: Float
) : Parcelable
}

View File

@ -0,0 +1,15 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NewStatus(
val status: String,
//@Json(name = "spoiler_text") val warningText: String,
@Json(name = "in_reply_to_id") val inReplyToId: String?,
val visibility: String,
val sensitive: Boolean,
@Json(name = "media_ids") val mediaIds: List<String>?
)

View File

@ -0,0 +1,15 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Relationship (
val id: String,
val following: Boolean,
@Json(name = "followed_by") val followedBy: Boolean,
val blocking: Boolean,
val muting: Boolean,
val requested: Boolean,
@Json(name = "showing_reblogs") val showingReblogs: Boolean
)

View File

@ -0,0 +1,78 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.*
@JsonClass(generateAdapter = true)
data class Status(
val id: String,
val url: String?, // not present if it's reblog
val account: Account,
@Json(name = "in_reply_to_id") val inReplyToId: String?,
@Json(name = "in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?,
val content: String,
@Json(name = "created_at") val createdAt: Date,
@Json(name = "reblogs_count") val reblogsCount: Int,
@Json(name = "favourites_count") val favouritesCount: Int,
val reblogged: Boolean,
val favourited: Boolean,
val sensitive: Boolean,
@Json(name = "spoiler_text") val spoilerText: String,
val visibility: Visibility,
@Json(name = "media_attachments") val attachments: List<Attachment>,
val mentions: List<Mention>,
val application: Application?
) {
val actionableId: String
get() = reblog?.id ?: id
val actionableStatus: Status
get() = reblog ?: this
enum class Visibility {
UNKNOWN,
@Json(name = "public")
PUBLIC,
@Json(name = "unlisted")
UNLISTED,
@Json(name = "private")
PRIVATE,
@Json(name = "direct")
DIRECT
}
fun rebloggingAllowed(): Boolean {
return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val status = other as Status?
return id == status?.id
}
override fun hashCode(): Int {
return id.hashCode()
}
@JsonClass(generateAdapter = true)
data class Mention (
val id: String,
val url: String,
val acct: String,
val username: String
)
@JsonClass(generateAdapter = true)
data class Application (
val name: String,
val website: String?
)
}

View File

@ -0,0 +1,144 @@
package at.connyduck.pixelcat.network
import at.connyduck.pixelcat.model.*
import at.connyduck.pixelcat.network.calladapter.NetworkResponse
import okhttp3.MultipartBody
import retrofit2.http.*
import retrofit2.http.Field
interface FediverseApi {
companion object {
const val PLACEHOLDER_DOMAIN = "x.placeholder"
const val DOMAIN_HEADER = "Domain"
}
@FormUrlEncoded
@POST("api/v1/apps")
suspend fun authenticateAppAsync(
@Header(DOMAIN_HEADER) domain: String,
@Field("client_name") clientName: String,
@Field("website") clientWebsite: String,
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String
): NetworkResponse<AppCredentials>
@FormUrlEncoded
@POST("oauth/token")
suspend fun fetchOAuthToken(
@Header(DOMAIN_HEADER) domain: String,
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String = "authorization_code"
): NetworkResponse<AccessToken>
@FormUrlEncoded
@POST("oauth/token")
suspend fun refreshOAuthToken(
@Header(DOMAIN_HEADER) domain: String,
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("refresh_token") refreshToken: String,
@Field("grant_type") grantType: String = "refresh_token"
): NetworkResponse<AccessToken>
@GET("api/v1/accounts/verify_credentials")
suspend fun accountVerifyCredentials(): NetworkResponse<Account>
@GET("api/v1/timelines/home")
suspend fun homeTimeline(
@Query("max_id") maxId: String? = null,
@Query("since_id") sinceId: String? = null,
@Query("limit") limit: Int? = null
): NetworkResponse<List<Status>>
@GET("api/v1/accounts/{id}/statuses")
suspend fun accountTimeline(
@Path("id") accountId: String,
@Query("max_id") maxId: String? = null,
@Query("since_id") sinceId: String? = null,
@Query("limit") limit: Int? = null,
@Query("exclude_replies") excludeReplies: Boolean? = false,
@Query("only_media") onlyMedia: Boolean? = true,
@Query("pinned") pinned: Boolean? = false
): NetworkResponse<Status>
@GET("api/v1/accounts/{id}")
suspend fun account(
@Path("id") accountId: String
): NetworkResponse<Account>
@GET("api/v1/accounts/{id}/statuses")
suspend fun accountStatuses(
@Path("id") accountId: String,
@Query("max_id") maxId: String? = null,
@Query("since_id") sinceId: String? = null,
@Query("limit") limit: Int? = null,
@Query("only_media") onlyMedia: Boolean? = null,
@Query("exclude_reblogs") excludeReblogs: Boolean? = null
): NetworkResponse<List<Status>>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/follow")
suspend fun followAccount(
@Path("id") accountId: String,
@Field("reblogs") showReblogs: Boolean
): NetworkResponse<Relationship>
@POST("api/v1/accounts/{id}/unfollow")
suspend fun unfollowAccount(
@Path("id") accountId: String
): NetworkResponse<Relationship>
@POST("api/v1/accounts/{id}/block")
suspend fun blockAccount(
@Path("id") accountId: String
): NetworkResponse<Relationship>
@POST("api/v1/accounts/{id}/unblock")
suspend fun unblockAccount(
@Path("id") accountId: String
): NetworkResponse<Relationship>
@POST("api/v1/accounts/{id}/mute")
suspend fun muteAccount(
@Path("id") accountId: String
): NetworkResponse<Relationship>
@POST("api/v1/accounts/{id}/unmute")
suspend fun unmuteAccount(
@Path("id") accountId: String
): NetworkResponse<Relationship>
@GET("api/v1/accounts/relationships")
suspend fun relationships(
@Query("id[]") accountIds: List<String>
): NetworkResponse<List<Relationship>>
@Multipart
@POST("api/v1/media")
suspend fun uploadMedia(
@Part file: MultipartBody.Part
): NetworkResponse<Attachment>
@POST("api/v1/statuses")
suspend fun createStatus(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Body status: NewStatus
): NetworkResponse<Status>
@POST("api/v1/statuses/{id}/favourite")
suspend fun favouriteStatus(
@Path("id") statusId: String
): NetworkResponse<Status>
@POST("api/v1/statuses/{id}/unfavourite")
suspend fun unfavouriteStatus(
@Path("id") statusId: String
): NetworkResponse<Status>
}

View File

@ -0,0 +1,51 @@
package at.connyduck.pixelcat.network
import at.connyduck.pixelcat.db.AccountManager
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// only switch domains if the request comes from retrofit
if (originalRequest.url.host == FediverseApi.PLACEHOLDER_DOMAIN) {
val currentAccount = runBlocking { accountManager.activeAccount() }
val builder = originalRequest.newBuilder()
val instanceHeader = originalRequest.header(FediverseApi.DOMAIN_HEADER)
if (instanceHeader != null) {
// use domain explicitly specified in custom header
builder.url(swapHost(originalRequest.url, instanceHeader))
builder.removeHeader(FediverseApi.DOMAIN_HEADER)
} else if (currentAccount != null) {
//use domain of current account
builder.url(swapHost(originalRequest.url, currentAccount.domain))
.header(
"Authorization",
"Bearer ${currentAccount.auth.accessToken}"
)
}
val newRequest = builder.build()
return chain.proceed(newRequest)
} else {
return chain.proceed(originalRequest)
}
}
private fun swapHost(url: HttpUrl, host: String): HttpUrl {
return url.newBuilder().host(host).build()
}
}

View File

@ -0,0 +1,22 @@
package at.connyduck.pixelcat.network
import at.connyduck.pixelcat.db.AccountManager
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
class RefreshTokenAuthenticator(private val accountManager: AccountManager) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val currentAccount = runBlocking { accountManager.activeAccount() }
// TODO
return null
}
}

View File

@ -0,0 +1,18 @@
package at.connyduck.pixelcat.network
import android.os.Build
import at.connyduck.pixelcat.BuildConfig
import okhttp3.Interceptor
import okhttp3.Response
class UserAgentInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestWithUserAgent = chain.request()
.newBuilder()
.header("User-Agent", "PixelCat/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE}")
.build()
return chain.proceed(requestWithUserAgent)
}
}

View File

@ -0,0 +1,16 @@
package at.connyduck.pixelcat.network.calladapter
import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type
class NetworkCallAdapter<S : Any>(
private val successType: Type
) : CallAdapter<S, Call<NetworkResponse<S>>> {
override fun responseType(): Type = successType
override fun adapt(call: Call<S>): Call<NetworkResponse<S>> {
return NetworkResponseCall(call)
}
}

View File

@ -0,0 +1,30 @@
package at.connyduck.pixelcat.network.calladapter
import java.io.IOException
sealed class NetworkResponse<out A : Any> {
data class Success<T : Any>(val body: T) : NetworkResponse<T>()
data class Failure(val reason: NetworkResponseError) : NetworkResponse<Nothing>()
inline fun <B> fold(onSuccess: (A) -> B, onFailure: (NetworkResponseError) -> B): B = when (this) {
is Success -> onSuccess(body)
is Failure -> onFailure(reason)
}
}
sealed class NetworkResponseError: Throwable() {
data class ApiError(val code: Int) : NetworkResponseError()
/**
* Network error
*/
data class NetworkError(val error: IOException) : NetworkResponseError()
/**
* For example, json parsing error
*/
data class UnknownError(val error: Throwable?) : NetworkResponseError()
}

View File

@ -0,0 +1,41 @@
package at.connyduck.pixelcat.network.calladapter
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class NetworkResponseAdapterFactory : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
// suspend functions wrap the response type in `Call`
if (Call::class.java != getRawType(returnType)) {
return null
}
// check first that the return type is `ParameterizedType`
check(returnType is ParameterizedType) {
"return type must be parameterized as Call<NetworkResponse<<Foo>> or Call<NetworkResponse<out Foo>>"
}
// get the response type inside the `Call` type
val responseType = getParameterUpperBound(0, returnType)
// if the response type is not ApiResponse then we can't handle this type, so we return null
if (getRawType(responseType) != NetworkResponse::class.java) {
return null
}
// the response type is ApiResponse and should be parameterized
check(responseType is ParameterizedType) { "Response must be parameterized as NetworkResponse<Foo> or NetworkResponse<out Foo>" }
val successBodyType = getParameterUpperBound(0, responseType)
return NetworkCallAdapter<Any>(successBodyType)
}
}

View File

@ -0,0 +1,68 @@
package at.connyduck.pixelcat.network.calladapter
import android.util.Log
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
internal class NetworkResponseCall<S : Any>(
private val delegate: Call<S>
) : Call<NetworkResponse<S>> {
override fun enqueue(callback: Callback<NetworkResponse<S>>) {
return delegate.enqueue(object : Callback<S> {
override fun onResponse(call: Call<S>, response: Response<S>) {
val body = response.body()
val errorbody = response.errorBody()?.string()
if (response.isSuccessful) {
if (body != null) {
callback.onResponse(
this@NetworkResponseCall,
Response.success(NetworkResponse.Success(body))
)
} else {
// Response is successful but the body is null
callback.onResponse(
this@NetworkResponseCall,
Response.success(NetworkResponse.Failure(NetworkResponseError.ApiError(response.code())))
)
}
} else {
callback.onResponse(
this@NetworkResponseCall,
Response.success(NetworkResponse.Failure(NetworkResponseError.ApiError(response.code())))
)
}
}
override fun onFailure(call: Call<S>, throwable: Throwable) {
Log.d("NetworkResponseCall", "Network response failed", throwable)
val networkResponse = when (throwable) {
is IOException -> NetworkResponse.Failure(NetworkResponseError.NetworkError(throwable))
else -> NetworkResponse.Failure(NetworkResponseError.UnknownError(throwable))
}
callback.onResponse(this@NetworkResponseCall, Response.success(networkResponse))
}
})
}
override fun isExecuted() = delegate.isExecuted
override fun clone() = NetworkResponseCall(delegate.clone())
override fun isCanceled() = delegate.isCanceled
override fun cancel() = delegate.cancel()
override fun execute(): Response<NetworkResponse<S>> {
throw UnsupportedOperationException("NetworkResponseCall doesn't support synchronized execution")
}
override fun request(): Request = delegate.request()
override fun timeout(): Timeout = delegate.timeout()
}

View File

@ -0,0 +1,20 @@
package at.connyduck.pixelcat.util
import android.os.Bundle
import androidx.fragment.app.Fragment
import java.lang.IllegalStateException
inline fun <reified T> Fragment.arg(key: String): T {
val value = arguments?.get(key)
if(value !is T) {
throw IllegalStateException("Argument $key is of wrong type")
}
return value
}
inline fun Fragment.withArgs(argsBuilder: Bundle.() -> Unit): Fragment {
this.arguments = Bundle().apply(argsBuilder)
return this
}

View File

@ -0,0 +1,62 @@
package at.connyduck.pixelcat.util
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
/**
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
*/
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
crossinline bindingInflater: (LayoutInflater) -> T) =
lazy(LazyThreadSafetyMode.NONE) {
bindingInflater(layoutInflater)
}
class FragmentViewBindingDelegate<T : ViewBinding>(
val fragment: Fragment,
val viewBindingFactory: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null
init {
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
fragment.viewLifecycleOwnerLiveData.observe(fragment,
Observer { t ->
t?.lifecycle?.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
binding = null
}
})
})
}
})
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
val binding = binding
if (binding != null) {
return binding
}
val lifecycle = fragment.viewLifecycleOwner.lifecycle
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
}
return viewBindingFactory(thisRef.requireView()).also { this@FragmentViewBindingDelegate.binding = it }
}
}
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
FragmentViewBindingDelegate(this, viewBindingFactory)

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator"
android:fromAlpha="0"
android:toAlpha="1"
android:duration="300" />

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator"
android:fromAlpha="1"
android:toAlpha="0"
android:duration="300" />

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners
android:topLeftRadius="12dp"
android:topRightRadius="12dp" />
<solid android:color="#f0f" />
</shape>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?attr/colorPrimary">
<!-- mask here... -->
<item android:id="@android:id/mask">
<shape>
<corners android:radius="10dp" />
<solid android:color="#000" />
</shape>
</item>
<item>
<selector>
<item android:state_selected="true">
<shape>
<corners android:radius="10dp" />
<solid android:color="?attr/colorPrimary" />
</shape>
</item>
<item android:state_selected="false">
<shape>
<corners android:radius="10dp" />
<solid android:color="@color/transparent" />
</shape>
</item>
</selector>
</item>
</ripple>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp"/>
<solid android:color="?android:attr/textColorTertiary"/>
</shape>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="false"
android:drawable="@drawable/ic_heart" />
<item android:state_selected="true"
android:drawable="@drawable/ic_heart_filled" />
</selector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:pathData="M19,12L5,12"
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M12,19l-7,-7l7,-7"
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:viewportHeight="512"
android:viewportWidth="512"
android:width="108dp">
<path android:fillAlpha="1"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
android:strokeWidth="1.0"/>
</vector>

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:height="64dp"
android:viewportHeight="512"
android:viewportWidth="500"
android:width="64dp">
<path android:fillAlpha="1"
android:fillType="nonZero"
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
android:strokeWidth="1.0">
<aapt:attr name="android:fillColor">
<gradient
android:endColor="?attr/pixelcat_gradient_color_start"
android:startColor="?attr/pixelcat_gradient_color_end"
android:endX="500"
android:endY="0"
android:startX="0"
android:startY="0"
android:type="linear"/>
</aapt:attr>
</path>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.84,4.61a5.5,5.5 0,0 0,-7.78 0L12,5.67l-1.06,-1.06a5.5,5.5 0,0 0,-7.78 7.78l1.06,1.06L12,21.23l7.78,-7.78 1.06,-1.06a5.5,5.5 0,0 0,0 -7.78z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?android:attr/textColorTertiary"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.84,4.61a5.5,5.5 0,0 0,-7.78 0L12,5.67l-1.06,-1.06a5.5,5.5 0,0 0,-7.78 7.78l1.06,1.06L12,21.23l7.78,-7.78 1.06,-1.06a5.5,5.5 0,0 0,0 -7.78z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#f00"
android:strokeColor="#f00"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,9l9,-7 9,7v11a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M9,22l0,-10l6,0l0,10"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View File

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

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"
android:strokeLineCap="round"/>
<path
android:pathData="M12,16L12,12"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"
android:strokeLineCap="round"/>
<path
android:pathData="M12,8L12,8"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle" >
<gradient
android:angle="0"
android:endColor="@color/pixelcat_gradient_warm_end"
android:centerColor="#E94057"
android:startColor="@color/pixelcat_gradient_warm_start"
android:type="linear" />
</shape>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:viewportHeight="512"
android:viewportWidth="512"
android:width="108dp">
<path android:fillAlpha="1"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
android:strokeWidth="1.0"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:pathData="M3,12L21,12"
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M3,6L21,6"
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M3,18L21,18"
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

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