commit 99038aa419be9b7e602938355de931d50a3c2883 Author: Conny Duck Date: Fri Jun 12 15:44:45 2020 +0200 initial commit diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f83eeaf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @connyduck \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6796e9e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: pixelcat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07807e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +/.DS_Store +/build +/captures +.externalNativeBuild +*.apk +*.aab +output.json diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9d3ef6a --- /dev/null +++ b/app/build.gradle.kts @@ -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 { + 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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..362d915 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/PixelcatApplication.kt b/app/src/main/kotlin/at/connyduck/pixelcat/PixelcatApplication.kt new file mode 100644 index 0000000..c42f15d --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/PixelcatApplication.kt @@ -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 { + return DaggerAppComponent.builder() + .application(this) + .build() + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/about/AboutActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/AboutActivity.kt new file mode 100644 index 0000000..318ec4b --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/AboutActivity.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseActivity.kt new file mode 100644 index 0000000..75e0c00 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseActivity.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseCard.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseCard.kt new file mode 100644 index 0000000..b7ecea4 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/about/licenses/LicenseCard.kt @@ -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) } + } + + } + +} + diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionAdapter.kt new file mode 100644 index 0000000..25a9894 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionAdapter.kt @@ -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, + private val onAccountSelected: (Long) -> Unit, + private val onAddAccount: () -> Unit +) : RecyclerView.Adapter() { + + 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) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionBottomSheet.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionBottomSheet.kt new file mode 100644 index 0000000..752ae47 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/accountselection/AccountSelectionBottomSheet.kt @@ -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 + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/menu/MenuBottomSheet.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/menu/MenuBottomSheet.kt new file mode 100644 index 0000000..66b67e5 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/bottomsheet/menu/MenuBottomSheet.kt @@ -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 + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeActivity.kt new file mode 100644 index 0000000..8e4da28 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeImageAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeImageAdapter.kt new file mode 100644 index 0000000..89226fc --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeImageAdapter.kt @@ -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( + object : DiffUtil.ItemCallback() { + 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) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeViewModel.kt new file mode 100644 index 0000000..c236bac --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/ComposeViewModel.kt @@ -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>() + + 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") +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/SendStatusService.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/SendStatusService.kt new file mode 100644 index 0000000..b2b0b23 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/compose/SendStatusService.kt @@ -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() + private val sendJobs = ConcurrentHashMap() + + + 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(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({ + 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, + val mediaDescriptions: List = emptyList(), + val inReplyToId: String? = null, + val savedTootUid: Int = 0, + var retries: Int = 0 +) : Parcelable diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/general/BaseActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/general/BaseActivity.kt new file mode 100644 index 0000000..3d04024 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/general/BaseActivity.kt @@ -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) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginActivity.kt new file mode 100644 index 0000000..5ab2b7b --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginActivity.kt @@ -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 { + + @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 + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginState.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginState.kt new file mode 100644 index 0000000..d357e49 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginState.kt @@ -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 +} + + diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginViewModel.kt new file mode 100644 index 0000000..288a749 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginViewModel.kt @@ -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().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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginWebViewActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginWebViewActivity.kt new file mode 100644 index 0000000..57ab288 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/login/LoginWebViewActivity.kt @@ -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 { + 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) + } + } + } +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainActivity.kt new file mode 100644 index 0000000..198dec5 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainActivity.kt @@ -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()!!)) + } + } + + +} + diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainFragmentAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainFragmentAdapter.kt new file mode 100644 index 0000000..8a27731 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainFragmentAdapter.kt @@ -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 + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainViewModel.kt new file mode 100644 index 0000000..2a04852 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/main/MainViewModel.kt @@ -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) + }, { + + }) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsFragment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsFragment.kt new file mode 100644 index 0000000..921bd37 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsFragment.kt @@ -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() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsViewModel.kt new file mode 100644 index 0000000..76f42a8 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,12 @@ +package at.connyduck.pixelcat.components.notifications + +import androidx.lifecycle.ViewModel +import javax.inject.Inject + +class NotificationsViewModel @Inject constructor( + +): ViewModel() { + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/GridSpacingItemDecoration.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/GridSpacingItemDecoration.kt new file mode 100644 index 0000000..9082ef5 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/GridSpacingItemDecoration.kt @@ -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 + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileActivity.kt new file mode 100644 index 0000000..f5b7f51 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileActivity.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileDataSourceFactory.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileDataSourceFactory.kt new file mode 100644 index 0000000..391de54 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileDataSourceFactory.kt @@ -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() { + + override fun create(): DataSource { + 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() { + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback + ) { + 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, callback: LoadCallback) { + 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, callback: LoadCallback) { + // we always load from top + } + + override fun getKey(item: Status) = item.id +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileFragment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileFragment.kt new file mode 100644 index 0000000..ed6a8f3 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileFragment.kt @@ -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) } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileHeaderAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileHeaderAdapter.kt new file mode 100644 index 0000000..04d9304 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileHeaderAdapter.kt @@ -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() { + + 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) { + 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) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileImageAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileImageAdapter.kt new file mode 100644 index 0000000..3a4984f --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileImageAdapter.kt @@ -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( + object: DiffUtil.ItemCallback() { + 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) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileViewModel.kt new file mode 100644 index 0000000..fab8a33 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/profile/ProfileViewModel.kt @@ -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>() + val relationship = MutableLiveData>() + val profileImages = MutableLiveData>() + + 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!! + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchFragment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchFragment.kt new file mode 100644 index 0000000..2a92643 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchFragment.kt @@ -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() + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchViewModel.kt new file mode 100644 index 0000000..6c3ce99 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/search/SearchViewModel.kt @@ -0,0 +1,12 @@ +package at.connyduck.pixelcat.components.search + +import androidx.lifecycle.ViewModel +import javax.inject.Inject + +class SearchViewModel @Inject constructor( + +): ViewModel() { + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/AppSettings.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/AppSettings.kt new file mode 100644 index 0000000..7f7c4cb --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/AppSettings.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/SettingsActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/SettingsActivity.kt new file mode 100644 index 0000000..b32e088 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/settings/SettingsActivity.kt @@ -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) + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/splash/SplashActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/splash/SplashActivity.kt new file mode 100644 index 0000000..3bdefd6 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/splash/SplashActivity.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt new file mode 100644 index 0000000..499513f --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt @@ -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") + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineImageAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineImageAdapter.kt new file mode 100644 index 0000000..5dacb09 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineImageAdapter.kt @@ -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() { + + var images: List = 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) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt new file mode 100644 index 0000000..2c90829 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt @@ -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() { + 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(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) + } +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineRemoteMediator.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineRemoteMediator.kt new file mode 100644 index 0000000..47b4d25 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineRemoteMediator.kt @@ -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() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): 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 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt new file mode 100644 index 0000000..8307204 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt @@ -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() + + 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!!) + } + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/util/Resource.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/Resource.kt new file mode 100644 index 0000000..c9003e6 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/Resource.kt @@ -0,0 +1,13 @@ +package at.connyduck.pixelcat.components.util + +sealed class UiState(open val data: T?) + +class Loading (override val data: T? = null) : UiState(data) + +class Success (override val data: T? = null) : UiState(data) + +class Error (override val data: T? = null, + val errorMessage: String? = null, + var consumed: Boolean = false, + val cause: Throwable? = null +): UiState(data) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/util/ThemeUtils.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/ThemeUtils.kt new file mode 100644 index 0000000..bf928e3 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/ThemeUtils.kt @@ -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") +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ContextExtensions.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ContextExtensions.kt new file mode 100644 index 0000000..ffedcc9 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ContextExtensions.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ViewExtensions.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ViewExtensions.kt new file mode 100644 index 0000000..88118d5 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/util/extension/ViewExtensions.kt @@ -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 + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/config/Config.kt b/app/src/main/kotlin/at/connyduck/pixelcat/config/Config.kt new file mode 100644 index 0000000..c14769d --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/config/Config.kt @@ -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") + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt new file mode 100644 index 0000000..f5112f1 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt @@ -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 + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppComponent.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppComponent.kt new file mode 100644 index 0000000..f044f25 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppComponent.kt @@ -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 { + + @Component.Builder + interface Builder { + @BindsInstance + fun application(app: PixelcatApplication): Builder + + fun build(): AppComponent + } + + override fun inject(app: PixelcatApplication) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppModule.kt new file mode 100644 index 0000000..feb74e9 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/AppModule.kt @@ -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) + } + + + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/FragmentModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/FragmentModule.kt new file mode 100644 index 0000000..4032212 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/FragmentModule.kt @@ -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 + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt new file mode 100644 index 0000000..690ae2f --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ServiceModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ServiceModule.kt new file mode 100644 index 0000000..0d3d4c9 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ServiceModule.kt @@ -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 + + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt new file mode 100644 index 0000000..1e4cac8 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt @@ -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, Provider>) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): 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) + +@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 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountDao.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountDao.kt new file mode 100644 index 0000000..fcfaa98 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountDao.kt @@ -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 + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountManager.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountManager.kt new file mode 100644 index 0000000..b1779d4 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/AccountManager.kt @@ -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 = 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 { + + // 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 + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/AppDatabase.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/AppDatabase.kt new file mode 100644 index 0000000..38c4088 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/AppDatabase.kt @@ -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 + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/Converters.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/Converters.kt new file mode 100644 index 0000000..c6efccb --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/Converters.kt @@ -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?): String { + val type = Types.newParameterizedType( + List::class.java, + Attachment::class.java + ) + return moshi.adapter>(type).toJson(attachmentList) + } + + @TypeConverter + fun jsonToAttachmentList(attachmentListJson: String?): List? { + if(attachmentListJson == null) { + return null + } + val type = Types.newParameterizedType( + List::class.java, + Attachment::class.java + ) + return moshi.adapter>(type).fromJson(attachmentListJson) + } + + @TypeConverter + fun dateToLong(date: Date): Long { + return date.time + } + + @TypeConverter + fun longToDate(date: Long): Date { + return Date(date) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt new file mode 100644 index 0000000..120af47 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt @@ -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) + + @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 + + @Query("DELETE FROM StatusEntity WHERE accountId = :accountId") + suspend fun clearAll(accountId: Long) + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/AccountEntity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/AccountEntity.kt new file mode 100644 index 0000000..1bdbe37 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/AccountEntity.kt @@ -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 +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineAccountEntity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineAccountEntity.kt new file mode 100644 index 0000000..d8b5a29 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineAccountEntity.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineStatusEntity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineStatusEntity.kt new file mode 100644 index 0000000..3d57e83 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/entitity/TimelineStatusEntity.kt @@ -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, + + 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 +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/AccessToken.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/AccessToken.kt new file mode 100644 index 0000000..4935690 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/AccessToken.kt @@ -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? +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/Account.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/Account.kt new file mode 100644 index 0000000..b45ebd3 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/Account.kt @@ -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, // nullable for backward compatibility + val fields: List?, //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? +): Parcelable + +@JsonClass(generateAdapter = true) +@Parcelize +data class Field ( + val name: String, + // val value: @WriteWith() 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 { + 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)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/AppCredentials.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/AppCredentials.kt new file mode 100644 index 0000000..e27846c --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/AppCredentials.kt @@ -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 +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/Attachment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/Attachment.kt new file mode 100644 index 0000000..7308cfe --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/Attachment.kt @@ -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 { + @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 +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/NewStatus.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/NewStatus.kt new file mode 100644 index 0000000..2d5f660 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/NewStatus.kt @@ -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? +) + diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/Relationship.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/Relationship.kt new file mode 100644 index 0000000..1cc824a --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/Relationship.kt @@ -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 +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/Status.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/Status.kt new file mode 100644 index 0000000..ebe3aab --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/Status.kt @@ -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, + val mentions: List, + 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? + ) + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt new file mode 100644 index 0000000..9bdbc08 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt @@ -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 + + @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 + + @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 + + + @GET("api/v1/accounts/verify_credentials") + suspend fun accountVerifyCredentials(): NetworkResponse + + @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> + + @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 + + @GET("api/v1/accounts/{id}") + suspend fun account( + @Path("id") accountId: String + ): NetworkResponse + + @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> + + @FormUrlEncoded + @POST("api/v1/accounts/{id}/follow") + suspend fun followAccount( + @Path("id") accountId: String, + @Field("reblogs") showReblogs: Boolean + ): NetworkResponse + + @POST("api/v1/accounts/{id}/unfollow") + suspend fun unfollowAccount( + @Path("id") accountId: String + ): NetworkResponse + + @POST("api/v1/accounts/{id}/block") + suspend fun blockAccount( + @Path("id") accountId: String + ): NetworkResponse + + @POST("api/v1/accounts/{id}/unblock") + suspend fun unblockAccount( + @Path("id") accountId: String + ): NetworkResponse + + @POST("api/v1/accounts/{id}/mute") + suspend fun muteAccount( + @Path("id") accountId: String + ): NetworkResponse + + @POST("api/v1/accounts/{id}/unmute") + suspend fun unmuteAccount( + @Path("id") accountId: String + ): NetworkResponse + + @GET("api/v1/accounts/relationships") + suspend fun relationships( + @Query("id[]") accountIds: List + ): NetworkResponse> + + @Multipart + @POST("api/v1/media") + suspend fun uploadMedia( + @Part file: MultipartBody.Part + ): NetworkResponse + + @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 + + @POST("api/v1/statuses/{id}/favourite") + suspend fun favouriteStatus( + @Path("id") statusId: String + ): NetworkResponse + + @POST("api/v1/statuses/{id}/unfavourite") + suspend fun unfavouriteStatus( + @Path("id") statusId: String + ): NetworkResponse +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/InstanceSwitchAuthInterceptor.kt new file mode 100644 index 0000000..1106787 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/InstanceSwitchAuthInterceptor.kt @@ -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() + } + + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/RefreshTokenAuthenticator.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/RefreshTokenAuthenticator.kt new file mode 100644 index 0000000..b8b26f1 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/RefreshTokenAuthenticator.kt @@ -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 + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/UserAgentInterceptor.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/UserAgentInterceptor.kt new file mode 100644 index 0000000..273ad9a --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/UserAgentInterceptor.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkCallAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkCallAdapter.kt new file mode 100644 index 0000000..159a330 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkCallAdapter.kt @@ -0,0 +1,16 @@ +package at.connyduck.pixelcat.network.calladapter + +import retrofit2.Call +import retrofit2.CallAdapter +import java.lang.reflect.Type + +class NetworkCallAdapter( + private val successType: Type +) : CallAdapter>> { + + override fun responseType(): Type = successType + + override fun adapt(call: Call): Call> { + return NetworkResponseCall(call) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponse.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponse.kt new file mode 100644 index 0000000..fef4830 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponse.kt @@ -0,0 +1,30 @@ +package at.connyduck.pixelcat.network.calladapter + +import java.io.IOException + +sealed class NetworkResponse { + + data class Success(val body: T) : NetworkResponse() + + data class Failure(val reason: NetworkResponseError) : NetworkResponse() + + inline fun 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() +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseAdapterFactory.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseAdapterFactory.kt new file mode 100644 index 0000000..bb1cd1d --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseAdapterFactory.kt @@ -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, + 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> or Call>" + } + + // 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 or NetworkResponse" } + + val successBodyType = getParameterUpperBound(0, responseType) + + return NetworkCallAdapter(successBodyType) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseCall.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseCall.kt new file mode 100644 index 0000000..1b4aa1c --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/calladapter/NetworkResponseCall.kt @@ -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( + private val delegate: Call +) : Call> { + + override fun enqueue(callback: Callback>) { + return delegate.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + 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, 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> { + throw UnsupportedOperationException("NetworkResponseCall doesn't support synchronized execution") + } + + override fun request(): Request = delegate.request() + + override fun timeout(): Timeout = delegate.timeout() +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/util/FragmentExtensions.kt b/app/src/main/kotlin/at/connyduck/pixelcat/util/FragmentExtensions.kt new file mode 100644 index 0000000..d1089c6 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/util/FragmentExtensions.kt @@ -0,0 +1,20 @@ +package at.connyduck.pixelcat.util + +import android.os.Bundle +import androidx.fragment.app.Fragment +import java.lang.IllegalStateException + +inline fun 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 +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/util/ViewBindingExtensions.kt b/app/src/main/kotlin/at/connyduck/pixelcat/util/ViewBindingExtensions.kt new file mode 100644 index 0000000..5fdc89d --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/util/ViewBindingExtensions.kt @@ -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 AppCompatActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T) = + lazy(LazyThreadSafetyMode.NONE) { + bindingInflater(layoutInflater) + } + +class FragmentViewBindingDelegate( + val fragment: Fragment, + val viewBindingFactory: (View) -> T +) : ReadOnlyProperty { + 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 Fragment.viewBinding(viewBindingFactory: (View) -> T) = + FragmentViewBindingDelegate(this, viewBindingFactory) \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..972e757 --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..9b48ae8 --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_bottom_sheet.xml b/app/src/main/res/drawable/background_bottom_sheet.xml new file mode 100644 index 0000000..48f944b --- /dev/null +++ b/app/src/main/res/drawable/background_bottom_sheet.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_selectable.xml b/app/src/main/res/drawable/background_selectable.xml new file mode 100644 index 0000000..d181cea --- /dev/null +++ b/app/src/main/res/drawable/background_selectable.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottomsheet_drag_handle.xml b/app/src/main/res/drawable/bottomsheet_drag_handle.xml new file mode 100644 index 0000000..8df5514 --- /dev/null +++ b/app/src/main/res/drawable/bottomsheet_drag_handle.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/heart_toggle.xml b/app/src/main/res/drawable/heart_toggle.xml new file mode 100644 index 0000000..2348169 --- /dev/null +++ b/app/src/main/res/drawable/heart_toggle.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..5baca43 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cat.xml b/app/src/main/res/drawable/ic_cat.xml new file mode 100644 index 0000000..6d3d0fb --- /dev/null +++ b/app/src/main/res/drawable/ic_cat.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_cat_small.xml b/app/src/main/res/drawable/ic_cat_small.xml new file mode 100644 index 0000000..30c9f6f --- /dev/null +++ b/app/src/main/res/drawable/ic_cat_small.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_dashboard_black_24dp.xml b/app/src/main/res/drawable/ic_dashboard_black_24dp.xml new file mode 100644 index 0000000..85b70f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..f356b14 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart_filled.xml b/app/src/main/res/drawable/ic_heart_filled.xml new file mode 100644 index 0000000..de285a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_filled.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..6e87074 --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_black_24dp.xml b/app/src/main/res/drawable/ic_home_black_24dp.xml new file mode 100644 index 0000000..de832bb --- /dev/null +++ b/app/src/main/res/drawable/ic_home_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..f856719 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..fdeb319 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6d3d0fb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..7d34bda --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml new file mode 100644 index 0000000..c2ce9a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_message.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_multiple.xml b/app/src/main/res/drawable/ic_multiple.xml new file mode 100644 index 0000000..ae182b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_multiple.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notifications_black_24dp.xml b/app/src/main/res/drawable/ic_notifications_black_24dp.xml new file mode 100644 index 0000000..fa1d5fe --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..e18ce8a --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..e1679a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_plus_background.xml b/app/src/main/res/drawable/ic_plus_background.xml new file mode 100644 index 0000000..09d23cc --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_plus_square.xml b/app/src/main/res/drawable/ic_plus_square.xml new file mode 100644 index 0000000..40f6a02 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_square.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml new file mode 100644 index 0000000..3679e7c --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..4fa1abc --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..417395e --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..9114afb --- /dev/null +++ b/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_users.xml b/app/src/main/res/drawable/ic_users.xml new file mode 100644 index 0000000..e9e7960 --- /dev/null +++ b/app/src/main/res/drawable/ic_users.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/pager_indicator_dot.xml b/app/src/main/res/drawable/pager_indicator_dot.xml new file mode 100644 index 0000000..57ff6ed --- /dev/null +++ b/app/src/main/res/drawable/pager_indicator_dot.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pixelcat_gradient.xml b/app/src/main/res/drawable/pixelcat_gradient.xml new file mode 100644 index 0000000..3c1d74c --- /dev/null +++ b/app/src/main/res/drawable/pixelcat_gradient.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/nunito.xml b/app/src/main/res/font/nunito.xml new file mode 100644 index 0000000..fc5d9c0 --- /dev/null +++ b/app/src/main/res/font/nunito.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/nunito_bold.ttf b/app/src/main/res/font/nunito_bold.ttf new file mode 100644 index 0000000..4ce517a Binary files /dev/null and b/app/src/main/res/font/nunito_bold.ttf differ diff --git a/app/src/main/res/font/nunito_regular.ttf b/app/src/main/res/font/nunito_regular.ttf new file mode 100644 index 0000000..aafdc88 Binary files /dev/null and b/app/src/main/res/font/nunito_regular.ttf differ diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml new file mode 100644 index 0000000..0fd78dc --- /dev/null +++ b/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + +