diff --git a/app/build.gradle b/app/build.gradle index f801f16085..a4a5bdf98c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,6 +51,8 @@ android { kotlinOptions { freeCompilerArgs = [ + // TODO: remvoe this flag after fully migrate to Kotlin. + "-Xjvm-default=all", "-Xno-param-assertions", "-Xno-call-assertions", "-Xno-receiver-assertions" diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/keylesspalace/tusky/ExampleInstrumentedTest.java deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java deleted file mode 100644 index a5bc0b04a3..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ /dev/null @@ -1,308 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky; - -import android.app.ActivityManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; -import com.keylesspalace.tusky.components.login.LoginActivity; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.interfaces.AccountSelectionListener; -import com.keylesspalace.tusky.interfaces.PermissionRequester; -import com.keylesspalace.tusky.settings.AppTheme; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.ActivityExtensions; -import com.keylesspalace.tusky.util.ThemeUtils; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; - -import javax.inject.Inject; - -import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; -import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions; - -public abstract class BaseActivity extends AppCompatActivity implements Injectable { - - public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"; - - private static final String TAG = "BaseActivity"; - - @Inject - @NonNull - public AccountManager accountManager; - - private static final int REQUESTER_NONE = Integer.MAX_VALUE; - private HashMap requesters; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { - overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit); - overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit); - } - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - - /* There isn't presently a way to globally change the theme of a whole application at - * runtime, just individual activities. So, each activity has to set its theme before any - * views are created. */ - String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue()); - Log.d("activeTheme", theme); - if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { - setTheme(R.style.TuskyBlackTheme); - } - - /* set the taskdescription programmatically, the theme would turn it blue */ - String appName = getString(R.string.app_name); - Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK); - - setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); - - int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); - getTheme().applyStyle(style, true); - - if(requiresLogin()) { - redirectIfNotLoggedIn(); - } - - requesters = new HashMap<>(); - } - - private boolean activityTransitionWasRequested() { - return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false); - } - - @Override - protected void attachBaseContext(Context newBase) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase); - - // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO - float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); - - Configuration configuration = newBase.getResources().getConfiguration(); - - // Adjust `fontScale` in the configuration. - // - // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the - // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return - // you to the original 100%, it leaves it at 80%. - // - // Instead, calculate the new scale from the application context. This is unaffected by - // changes to the base context. It does contain contain any changes to the font scale from - // "Settings > Display > Font size" in the device settings, so scaling performed here - // is in addition to any scaling in the device settings. - Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); - - // This only adjusts the fonts, anything measured in `dp` is unaffected by this. - // You can try to adjust `densityDpi` as shown in the commented out code below. This - // works, to a point. However, dialogs do not react well to this. Beyond a certain - // scale (~ 120%) the right hand edge of the dialog will clip off the right of the - // screen. - // - // So for now, just adjust the font scale - // - // val displayMetrics = appContext.resources.displayMetrics - // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) - configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; - - Context fontScaleContext = newBase.createConfigurationContext(configuration); - - super.attachBaseContext(fontScaleContext); - } - - protected boolean requiresLogin() { - return true; - } - - private static int textStyle(String name) { - int style; - switch (name) { - case "smallest": - style = R.style.TextSizeSmallest; - break; - case "small": - style = R.style.TextSizeSmall; - break; - case "medium": - default: - style = R.style.TextSizeMedium; - break; - case "large": - style = R.style.TextSizeLarge; - break; - case "largest": - style = R.style.TextSizeLargest; - break; - } - return style; - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - getOnBackPressedDispatcher().onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - public void finish() { - super.finish(); - // if this activity was opened with slide-in, close it with slide out - if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { - overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit); - } - } - - protected void redirectIfNotLoggedIn() { - AccountEntity account = accountManager.getActiveAccount(); - if (account == null) { - Intent intent = new Intent(this, LoginActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - ActivityExtensions.startActivityWithSlideInAnimation(this, intent); - finish(); - } - } - - protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { - if (anyView != null) { - Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - bar.show(); - } - } - - public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { - List accounts = accountManager.getAllAccountsOrderedByActive(); - AccountEntity activeAccount = accountManager.getActiveAccount(); - - switch(accounts.size()) { - case 1: - listener.onAccountSelected(activeAccount); - return; - case 2: - if (!showActiveAccount) { - for (AccountEntity account : accounts) { - if (activeAccount != account) { - listener.onAccountSelected(account); - return; - } - } - } - break; - } - - if (!showActiveAccount && activeAccount != null) { - accounts.remove(activeAccount); - } - AccountSelectionAdapter adapter = new AccountSelectionAdapter(this); - adapter.addAll(accounts); - - new AlertDialog.Builder(this) - .setTitle(dialogTitle) - .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) - .show(); - } - - public @Nullable String getOpenAsText() { - List accounts = accountManager.getAllAccountsOrderedByActive(); - switch (accounts.size()) { - case 0: - case 1: - return null; - case 2: - for (AccountEntity account : accounts) { - if (account != accountManager.getActiveAccount()) { - return String.format(getString(R.string.action_open_as), account.getFullName()); - } - } - return null; - default: - return String.format(getString(R.string.action_open_as), "…"); - } - } - - public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { - accountManager.setActiveAccount(account.getId()); - Intent intent = MainActivity.redirectIntent(this, account.getId(), url); - - startActivity(intent); - finish(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requesters.containsKey(requestCode)) { - PermissionRequester requester = requesters.remove(requestCode); - requester.onRequestPermissionsResult(permissions, grantResults); - } - } - - public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) { - ArrayList permissionsToRequest = new ArrayList<>(); - for(String permission: permissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - permissionsToRequest.add(permission); - } - } - if (permissionsToRequest.isEmpty()) { - int[] permissionsAlreadyGranted = new int[permissions.length]; - requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted); - return; - } - - int newKey = requester == null ? REQUESTER_NONE : requesters.size(); - if (newKey != REQUESTER_NONE) { - requesters.put(newKey, requester); - } - String[] permissionsCopy = new String[permissionsToRequest.size()]; - permissionsToRequest.toArray(permissionsCopy); - ActivityCompat.requestPermissions(this, permissionsCopy, newKey); - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt new file mode 100644 index 0000000000..257318eb97 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -0,0 +1,308 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky + +import android.app.ActivityManager.TaskDescription +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import android.view.View +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent +import com.keylesspalace.tusky.adapter.AccountSelectionAdapter +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.interfaces.PermissionRequester +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME +import com.keylesspalace.tusky.util.isBlack +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions +import javax.inject.Inject + +abstract class BaseActivity : AppCompatActivity(), Injectable { + @Inject + lateinit var accountManager: AccountManager + + private lateinit var requesters: HashMap + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { + overrideActivityTransition( + OVERRIDE_TRANSITION_OPEN, + R.anim.activity_open_enter, + R.anim.activity_open_exit + ) + overrideActivityTransition( + OVERRIDE_TRANSITION_CLOSE, + R.anim.activity_close_enter, + R.anim.activity_close_exit + ) + } + + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + + /* There isn't presently a way to globally change the theme of a whole application at + * runtime, just individual activities. So, each activity has to set its theme before any + * views are created. */ + val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) + Log.d("activeTheme", theme.orEmpty()) + if (isBlack(resources.configuration, theme)) { + setTheme(R.style.TuskyBlackTheme) + } + + /* set the task description programmatically, the theme would turn it blue */ + val appName = getString(R.string.app_name) + val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + val recentsBackgroundColor = MaterialColors.getColor( + this, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) + + setTaskDescription(TaskDescription(appName, appIcon, recentsBackgroundColor)) + + val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")) + getTheme().applyStyle(style, true) + + if (requiresLogin()) { + redirectIfNotLoggedIn() + } + + requesters = HashMap() + } + + private fun activityTransitionWasRequested(): Boolean { + return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false) + } + + override fun attachBaseContext(newBase: Context) { + val preferences = PreferenceManager.getDefaultSharedPreferences(newBase) + + // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO + val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f) + + val configuration = newBase.resources.configuration + + // Adjust `fontScale` in the configuration. + // + // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the + // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return + // you to the original 100%, it leaves it at 80%. + // + // Instead, calculate the new scale from the application context. This is unaffected by + // changes to the base context. It does contain contain any changes to the font scale from + // "Settings > Display > Font size" in the device settings, so scaling performed here + // is in addition to any scaling in the device settings. + val appConfiguration = newBase.applicationContext.resources.configuration + + // This only adjusts the fonts, anything measured in `dp` is unaffected by this. + // You can try to adjust `densityDpi` as shown in the commented out code below. This + // works, to a point. However, dialogs do not react well to this. Beyond a certain + // scale (~ 120%) the right hand edge of the dialog will clip off the right of the + // screen. + // + // So for now, just adjust the font scale + // + // val displayMetrics = appContext.resources.displayMetrics + // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f + + val fontScaleContext = newBase.createConfigurationContext(configuration) + + super.attachBaseContext(fontScaleContext) + } + + protected open fun requiresLogin(): Boolean { + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun finish() { + super.finish() + // if this activity was opened with slide-in, close it with slide out + if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { + overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit) + } + } + + protected fun redirectIfNotLoggedIn() { + val account = accountManager.activeAccount + if (account == null) { + val intent = Intent(this, LoginActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + this.startActivityWithSlideInAnimation(intent) + finish() + } + } + + protected fun showErrorDialog( + anyView: View, + @StringRes descriptionId: Int, + @StringRes actionId: Int, + listener: View.OnClickListener? + ) { + Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT).apply { + setAction(actionId, listener) + show() + } + } + + fun showAccountChooserDialog( + dialogTitle: CharSequence?, + showActiveAccount: Boolean, + listener: AccountSelectionListener + ) { + val accounts = accountManager.getAllAccountsOrderedByActive().toMutableList() + val activeAccount = accountManager.activeAccount + + when (accounts.size) { + 1 -> { + listener.onAccountSelected(activeAccount!!) + return + } + + 2 -> if (!showActiveAccount) { + for (account in accounts) { + if (activeAccount !== account) { + listener.onAccountSelected(account) + return + } + } + } + } + if (!showActiveAccount && activeAccount != null) { + accounts.remove(activeAccount) + } + val adapter = AccountSelectionAdapter(this) + adapter.addAll(accounts) + + AlertDialog.Builder(this) + .setTitle(dialogTitle) + .setAdapter(adapter) { _, index -> + listener.onAccountSelected( + accounts[index] + ) + } + .show() + } + + val openAsText: String? + get() { + val accounts = accountManager.getAllAccountsOrderedByActive() + when (accounts.size) { + 0, 1 -> return null + 2 -> { + for (account in accounts) { + if (account !== accountManager.activeAccount) { + return String.format( + getString(R.string.action_open_as), + account.fullName + ) + } + } + return null + } + + else -> return String.format(getString(R.string.action_open_as), "…") + } + } + + fun openAsAccount(url: String, account: AccountEntity) { + accountManager.setActiveAccount(account.id) + val intent = redirectIntent(this, account.id, url) + + startActivity(intent) + finish() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requesters.containsKey(requestCode)) { + requesters.remove(requestCode)?.onRequestPermissionsResult(permissions, grantResults) + } + } + + fun requestPermissions(permissions: Array, requester: PermissionRequester) { + val permissionsToRequest = ArrayList() + for (permission in permissions) { + if ( + ContextCompat.checkSelfPermission(this, permission) + != PackageManager.PERMISSION_GRANTED + ) { + permissionsToRequest.add(permission) + } + } + if (permissionsToRequest.isEmpty()) { + val permissionsAlreadyGranted = IntArray(permissions.size) + requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted) + return + } + + val newKey = requesters.size + if (newKey != REQUESTER_NONE) { + requesters[newKey] = requester + } + val permissionsCopy = arrayOfNulls(permissionsToRequest.size) + permissionsToRequest.toArray(permissionsCopy) + ActivityCompat.requestPermissions(this, permissionsCopy, newKey) + } + + companion object { + private const val TAG = "BaseActivity" + private const val REQUESTER_NONE = Int.MAX_VALUE + const val OPEN_WITH_SLIDE_IN: String = "OPEN_WITH_SLIDE_IN" + + private fun textStyle(name: String?): Int { + return when (name) { + "smallest" -> R.style.TextSizeSmallest + "small" -> R.style.TextSizeSmall + "medium" -> R.style.TextSizeMedium + "large" -> R.style.TextSizeLarge + "largest" -> R.style.TextSizeLargest + else -> R.style.TextSizeMedium + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 499d080f75..1b8fe89b23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -62,6 +62,7 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition import com.google.android.material.color.MaterialColors +import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator @@ -1215,7 +1216,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false) } - override fun getActionButton() = binding.composeButton + override val actionButton: FloatingActionButton get() = binding.composeButton override fun androidInjector() = androidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java deleted file mode 100644 index bb5a78e4eb..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.DynamicDrawableSpan; -import android.text.style.ImageSpan; -import android.view.View; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.ViewUtils; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.NoUnderlineURLSpan; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewExtensionsKt; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.text.DateFormat; -import java.util.Date; - -public class StatusDetailedViewHolder extends StatusBaseViewHolder { - private final TextView reblogs; - private final TextView favourites; - private final View infoDivider; - - private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - - public StatusDetailedViewHolder(@NonNull View view) { - super(view); - reblogs = view.findViewById(R.id.status_reblogs); - favourites = view.findViewById(R.id.status_favourites); - infoDivider = view.findViewById(R.id.status_info_divider); - } - - @Override - protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { - - Status status = statusViewData.getActionable(); - - Status.Visibility visibility = status.getVisibility(); - Context context = metaInfo.getContext(); - - Drawable visibilityIcon = getVisibilityIcon(visibility); - CharSequence visibilityString = getVisibilityDescription(context, visibility); - - SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString); - - if (visibilityIcon != null) { - ImageSpan visibilityIconSpan = new ImageSpan( - visibilityIcon, - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE - ); - sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - String metadataJoiner = context.getString(R.string.metadata_joiner); - - Date createdAt = status.getCreatedAt(); - if (createdAt != null) { - sb.append(" "); - sb.append(dateFormat.format(createdAt)); - } - - Date editedAt = status.getEditedAt(); - - if (editedAt != null) { - String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt)); - - sb.append(metadataJoiner); - int spanStart = sb.length(); - int spanEnd = spanStart + editedAtString.length(); - - sb.append(editedAtString); - - if (statusViewData.getStatus().getEditedAt() != null) { - NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") { - @Override - public void onClick(@NonNull View view) { - listener.onShowEdits(getBindingAdapterPosition()); - } - }; - - sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - - String language = status.getLanguage(); - - if (language != null) { - sb.append(metadataJoiner); - sb.append(language.toUpperCase()); - } - - Status.Application app = status.getApplication(); - - if (app != null) { - sb.append(metadataJoiner); - - if (app.getWebsite() != null) { - CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); - sb.append(text); - } else { - sb.append(app.getName()); - } - } - - metaInfo.setMovementMethod(LinkMovementMethod.getInstance()); - metaInfo.setText(sb); - } - - private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { - reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount)); - favourites.setText(getFavsText(favourites.getContext(), favCount)); - - reblogs.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onShowReblogs(position); - } - }); - favourites.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onShowFavs(position); - } - }); - } - - @Override - public void setupWithStatus(@NonNull final StatusViewData.Concrete status, - @NonNull final StatusActionListener listener, - @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { - // We never collapse statuses in the detail view - StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? - status.copyWithCollapsed(false) : - status; - - super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); - setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status - if (payloads == null) { - Status actionable = uncollapsedStatus.getActionable(); - - if (!statusDisplayOptions.hideStats()) { - setReblogAndFavCount(actionable.getReblogsCount(), - actionable.getFavouritesCount(), listener); - } else { - hideQuantitativeStats(); - } - } - } - - private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) { - - if (visibility == null) { - return null; - } - - int visibilityIcon; - switch (visibility) { - case PUBLIC: - visibilityIcon = R.drawable.ic_public_24dp; - break; - case UNLISTED: - visibilityIcon = R.drawable.ic_lock_open_24dp; - break; - case PRIVATE: - visibilityIcon = R.drawable.ic_lock_outline_24dp; - break; - case DIRECT: - visibilityIcon = R.drawable.ic_email_24dp; - break; - default: - return null; - } - - final Drawable visibilityDrawable = AppCompatResources.getDrawable( - this.metaInfo.getContext(), visibilityIcon - ); - if (visibilityDrawable == null) { - return null; - } - - final int size = (int) this.metaInfo.getTextSize(); - visibilityDrawable.setBounds( - 0, - 0, - size, - size - ); - visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor()); - - return visibilityDrawable; - } - - private void hideQuantitativeStats() { - reblogs.setVisibility(View.GONE); - favourites.setVisibility(View.GONE); - infoDivider.setVisibility(View.GONE); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.kt new file mode 100644 index 0000000000..f3dcfa576d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.kt @@ -0,0 +1,187 @@ +package com.keylesspalace.tusky.adapter + +import android.graphics.drawable.Drawable +import android.os.Build +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.DynamicDrawableSpan +import android.text.style.ImageSpan +import android.view.View +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.text.buildSpannedString +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.NoUnderlineURLSpan +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.createClickableText +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.text.DateFormat +import java.util.Locale + +class StatusDetailedViewHolder(view: View) : StatusBaseViewHolder(view) { + private val reblogs: TextView = view.findViewById(R.id.status_reblogs) + private val favourites: TextView = view.findViewById(R.id.status_favourites) + private val infoDivider: View = view.findViewById(R.id.status_info_divider) + + override fun setMetaData( + statusViewData: StatusViewData.Concrete, + statusDisplayOptions: StatusDisplayOptions, + listener: StatusActionListener + ) { + val status = statusViewData.actionable + + val visibility = status.visibility + val context = metaInfo.context + + val visibilityIcon = getVisibilityIcon(visibility) + val visibilityString = getVisibilityDescription(context, visibility) + + metaInfo.movementMethod = LinkMovementMethod.getInstance() + metaInfo.text = buildSpannedString { + if (visibilityIcon != null) { + val visibilityIconSpan = ImageSpan( + visibilityIcon, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) DynamicDrawableSpan.ALIGN_CENTER else DynamicDrawableSpan.ALIGN_BASELINE + ) + setSpan( + visibilityIconSpan, + 0, + visibilityString.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + val metadataJoiner = context.getString(R.string.metadata_joiner) + + val createdAt = status.createdAt + append(" ") + append(dateFormat.format(createdAt)) + + val editedAt = status.editedAt + + if (editedAt != null) { + val editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt)) + + append(metadataJoiner) + val spanStart = length + val spanEnd = spanStart + editedAtString.length + + append(editedAtString) + + if (statusViewData.status.editedAt != null) { + val editedClickSpan: NoUnderlineURLSpan = object : NoUnderlineURLSpan("") { + override fun onClick(view: View) { + listener.onShowEdits(bindingAdapterPosition) + } + } + + setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + val language = status.language + + if (language != null) { + append(metadataJoiner) + append(language.uppercase(Locale.getDefault())) + } + + val app = status.application + + if (app != null) { + append(metadataJoiner) + + if (app.website != null) { + val text = createClickableText(app.name, app.website) + append(text) + } else { + append(app.name) + } + } + } + } + + private fun setReblogAndFavCount( + reblogCount: Int, + favCount: Int, + listener: StatusActionListener + ) { + reblogs.text = getReblogsText(reblogs.context, reblogCount) + favourites.text = getFavsText(favourites.context, favCount) + + reblogs.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onShowReblogs(position) + } + } + favourites.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onShowFavs(position) + } + } + } + + override fun setupWithStatus( + status: StatusViewData.Concrete, + listener: StatusActionListener, + statusDisplayOptions: StatusDisplayOptions, + payloads: Any? + ) { + // We never collapse statuses in the detail view + val uncollapsedStatus = if (status.isCollapsible && status.isCollapsed) status.copyWithCollapsed(false) else status + + super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads) + setupCard( + uncollapsedStatus, + status.isExpanded, + CardViewMode.FULL_WIDTH, + statusDisplayOptions, + listener + ) // Always show card for detailed status + if (payloads == null) { + val actionable = uncollapsedStatus.actionable + + if (!statusDisplayOptions.hideStats) { + setReblogAndFavCount( + actionable.reblogsCount, + actionable.favouritesCount, + listener + ) + } else { + hideQuantitativeStats() + } + } + } + + private fun getVisibilityIcon(visibility: Status.Visibility): Drawable? { + val visibilityIcon = when (visibility) { + Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp + Status.Visibility.DIRECT -> R.drawable.ic_email_24dp + else -> return null + } + val visibilityDrawable = AppCompatResources.getDrawable(metaInfo.context, visibilityIcon) ?: return null + val size = metaInfo.textSize.toInt() + visibilityDrawable.setBounds(0, 0, size, size) + visibilityDrawable.setTint(metaInfo.currentTextColor) + return visibilityDrawable + } + + private fun hideQuantitativeStats() { + reblogs.isVisible = false + favourites.isVisible = false + infoDivider.isVisible = false + } + + companion object { + private val dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java deleted file mode 100644 index 327f7cfbb8..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ /dev/null @@ -1,170 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.adapter; - -import android.content.Context; -import android.text.InputFilter; -import android.text.TextUtils; -import android.view.View; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Filter; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.NumberUtils; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class StatusViewHolder extends StatusBaseViewHolder { - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private final TextView statusInfo; - private final Button contentCollapseButton; - private final TextView favouritedCountLabel; - private final TextView reblogsCountLabel; - - public StatusViewHolder(@NonNull View itemView) { - super(itemView); - statusInfo = itemView.findViewById(R.id.status_info); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); - favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count); - reblogsCountLabel = itemView.findViewById(R.id.status_insets); - } - - @Override - public void setupWithStatus(@NonNull StatusViewData.Concrete status, - @NonNull final StatusActionListener listener, - @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { - if (payloads == null) { - - boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); - boolean expanded = status.isExpanded(); - - setupCollapsedState(sensitive, expanded, status, listener); - - Status reblogging = status.getRebloggingStatus(); - if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { - hideStatusInfo(); - } else { - String rebloggedByDisplayName = reblogging.getAccount().getName(); - setRebloggedByDisplayName(rebloggedByDisplayName, - reblogging.getAccount().getEmojis(), statusDisplayOptions); - statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); - } - - } - - reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); - favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); - setFavouritedCount(status.getActionable().getFavouritesCount()); - setReblogsCount(status.getActionable().getReblogsCount()); - - super.setupWithStatus(status, listener, statusDisplayOptions, payloads); - } - - private void setRebloggedByDisplayName(final CharSequence name, - final List accountEmoji, - final StatusDisplayOptions statusDisplayOptions) { - Context context = statusInfo.getContext(); - CharSequence wrappedName = StringUtils.unicodeWrap(name); - CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() - ); - statusInfo.setText(emojifiedText); - statusInfo.setVisibility(View.VISIBLE); - } - - // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - protected void setPollInfo(final boolean ownPoll) { - statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); - statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); - statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); - statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0); - statusInfo.setVisibility(View.VISIBLE); - } - - protected void setReblogsCount(int reblogsCount) { - reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000)); - } - - protected void setFavouritedCount(int favouritedCount) { - favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000)); - } - - protected void hideStatusInfo() { - statusInfo.setVisibility(View.GONE); - } - - private void setupCollapsedState(boolean sensitive, - boolean expanded, - final StatusViewData.Concrete status, - final StatusActionListener listener) { - /* input filter for TextViews have to be set before text */ - if (status.isCollapsible() && (!sensitive || expanded)) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) - listener.onContentCollapsedChange(!status.isCollapsed(), position); - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (status.isCollapsed()) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - content.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - content.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - content.setFilters(NO_INPUT_FILTER); - } - } - - public void showStatusContent(boolean show) { - super.showStatusContent(show); - contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); - } - - @Override - protected void toggleExpandedState(boolean sensitive, - boolean expanded, - @NonNull StatusViewData.Concrete status, - @NonNull StatusDisplayOptions statusDisplayOptions, - @NonNull final StatusActionListener listener) { - - setupCollapsedState(sensitive, expanded, status, listener); - - super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.kt new file mode 100644 index 0000000000..9e0867b7f7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.kt @@ -0,0 +1,172 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.adapter + +import android.text.InputFilter +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.formatNumber +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.StatusViewData + +class StatusViewHolder(itemView: View) : StatusBaseViewHolder(itemView) { + private val statusInfo: TextView = itemView.findViewById(R.id.status_info) + private val contentCollapseButton: Button = itemView.findViewById(R.id.button_toggle_content) + private val favouritedCountLabel: TextView = itemView.findViewById(R.id.status_favourites_count) + private val reblogsCountLabel: TextView = itemView.findViewById(R.id.status_insets) + + override fun setupWithStatus( + status: StatusViewData.Concrete, + listener: StatusActionListener, + statusDisplayOptions: StatusDisplayOptions, + payloads: Any? + ) { + if (payloads == null) { + val sensitive = status.actionable.spoilerText.isNotEmpty() + val expanded = status.isExpanded + + setupCollapsedState(sensitive, expanded, status, listener) + + val reblogging = status.rebloggingStatus + if (reblogging == null || status.filterAction == Filter.Action.WARN) { + hideStatusInfo() + } else { + val rebloggedByDisplayName = reblogging.account.name + setRebloggedByDisplayName( + rebloggedByDisplayName, + reblogging.account.emojis, + statusDisplayOptions + ) + statusInfo.setOnClickListener { v: View? -> + listener.onOpenReblog( + bindingAdapterPosition + ) + } + } + } + + reblogsCountLabel.isInvisible = !statusDisplayOptions.showStatsInline + favouritedCountLabel.isInvisible = !statusDisplayOptions.showStatsInline + setFavouritedCount(status.actionable.favouritesCount) + setReblogsCount(status.actionable.reblogsCount) + + super.setupWithStatus(status, listener, statusDisplayOptions, payloads) + } + + private fun setRebloggedByDisplayName( + name: CharSequence, + accountEmoji: List?, + statusDisplayOptions: StatusDisplayOptions + ) { + val context = statusInfo.context + val wrappedName: CharSequence = name.unicodeWrap() + val boostedText: CharSequence = context.getString(R.string.post_boosted_format, wrappedName) + val emojifiedText = boostedText.emojify( + accountEmoji, + statusInfo, + statusDisplayOptions.animateEmojis + ) + statusInfo.text = emojifiedText + statusInfo.isVisible = true + } + + // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed + fun setPollInfo(ownPoll: Boolean) { + statusInfo.setText(if (ownPoll) R.string.poll_ended_created else R.string.poll_ended_voted) + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) + statusInfo.compoundDrawablePadding = + Utils.dpToPx(statusInfo.context, 10) + statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.context, 28), 0, 0, 0) + statusInfo.isVisible = true + } + + protected fun setReblogsCount(reblogsCount: Int) { + reblogsCountLabel.text = formatNumber(reblogsCount.toLong(), 1000) + } + + protected fun setFavouritedCount(favouritedCount: Int) { + favouritedCountLabel.text = formatNumber(favouritedCount.toLong(), 1000) + } + + fun hideStatusInfo() { + statusInfo.isVisible = false + } + + private fun setupCollapsedState( + sensitive: Boolean, + expanded: Boolean, + status: StatusViewData.Concrete, + listener: StatusActionListener + ) { + /* input filter for TextViews have to be set before text */ + if (status.isCollapsible && (!sensitive || expanded)) { + contentCollapseButton.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onContentCollapsedChange( + !status.isCollapsed, + position + ) + } + } + + contentCollapseButton.isVisible = true + if (status.isCollapsed) { + contentCollapseButton.setText(R.string.post_content_warning_show_more) + content.filters = COLLAPSE_INPUT_FILTER + } else { + contentCollapseButton.setText(R.string.post_content_warning_show_less) + content.filters = NO_INPUT_FILTER + } + } else { + contentCollapseButton.isVisible = false + content.filters = NO_INPUT_FILTER + } + } + + override fun showStatusContent(show: Boolean) { + super.showStatusContent(show) + contentCollapseButton.isVisible = show + } + + override fun toggleExpandedState( + sensitive: Boolean, + expanded: Boolean, + status: StatusViewData.Concrete, + statusDisplayOptions: StatusDisplayOptions, + listener: StatusActionListener + ) { + setupCollapsedState(sensitive, expanded, status, listener) + + super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener) + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 35ec65c7ae..a6b474a6ff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -1088,7 +1088,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide return false } - override fun getActionButton(): FloatingActionButton? { + override val actionButton: FloatingActionButton? get() { return if (!blocking) { binding.accountFloatingActionButton } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java deleted file mode 100644 index 3c3103e0d7..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ /dev/null @@ -1,177 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.components.conversation; - -import android.content.Context; -import android.text.InputFilter; -import android.text.TextUtils; -import android.view.View; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.List; - -public class ConversationViewHolder extends StatusBaseViewHolder { - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private final TextView conversationNameTextView; - private final Button contentCollapseButton; - private final ImageView[] avatars; - - private final StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener listener; - - ConversationViewHolder(View itemView, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener listener) { - super(itemView); - conversationNameTextView = itemView.findViewById(R.id.conversation_name); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); - avatars = new ImageView[]{ - avatar, - itemView.findViewById(R.id.status_avatar_1), - itemView.findViewById(R.id.status_avatar_2) - }; - this.statusDisplayOptions = statusDisplayOptions; - - this.listener = listener; - } - - void setupWithConversation( - @NonNull ConversationViewData conversation, - @Nullable Object payloads - ) { - - StatusViewData.Concrete statusViewData = conversation.getLastStatus(); - Status status = statusViewData.getStatus(); - - if (payloads == null) { - TimelineAccount account = status.getAccount(); - - setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener); - - setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); - setUsername(account.getUsername()); - setMetaData(statusViewData, statusDisplayOptions, listener); - setIsReply(status.getInReplyToId() != null); - setFavourited(status.getFavourited()); - setBookmarked(status.getBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.getSensitive(); - if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), - statusDisplayOptions.useBlurhash()); - - if (attachments.size() == 0) { - hideSensitiveMediaWarning(); - } - // Hide the unused label. - for (TextView mediaLabel : mediaLabels) { - mediaLabel.setVisibility(View.GONE); - } - } else { - setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); - // Hide all unused views. - mediaPreview.setVisibility(View.GONE); - hideSensitiveMediaWarning(); - } - - setupButtons(listener, account.getId(), statusViewData.getContent().toString(), - statusDisplayOptions); - - setSpoilerAndContent(statusViewData, statusDisplayOptions, listener); - - setConversationName(conversation.getAccounts()); - - setAvatars(conversation.getAccounts()); - } else { - if (payloads instanceof List) { - for (Object item : (List) payloads) { - if (Key.KEY_CREATED.equals(item)) { - setMetaData(statusViewData, statusDisplayOptions, listener); - } - } - } - } - } - - private void setConversationName(List accounts) { - Context context = conversationNameTextView.getContext(); - String conversationName = ""; - if (accounts.size() == 1) { - conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); - } else if (accounts.size() == 2) { - conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername()); - } else if (accounts.size() > 2) { - conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); - } - - conversationNameTextView.setText(conversationName); - } - - private void setAvatars(List accounts) { - for (int i = 0; i < avatars.length; i++) { - ImageView avatarView = avatars[i]; - if (i < accounts.size()) { - ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, - avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); - avatarView.setVisibility(View.VISIBLE); - } else { - avatarView.setVisibility(View.GONE); - } - } - } - - private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) { - /* input filter for TextViews have to be set before text */ - if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) - listener.onContentCollapsedChange(!collapsed, position); - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (collapsed) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - content.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - content.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - content.setFilters(NO_INPUT_FILTER); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.kt new file mode 100644 index 0000000000..69dcf62465 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.kt @@ -0,0 +1,185 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.components.conversation + +import android.text.InputFilter +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.loadAvatar + +class ConversationViewHolder( + itemView: View, + private val statusDisplayOptions: StatusDisplayOptions, + private val listener: StatusActionListener +) : StatusBaseViewHolder(itemView) { + private val conversationNameTextView: TextView = itemView.findViewById(R.id.conversation_name) + private val contentCollapseButton: Button = itemView.findViewById(R.id.button_toggle_content) + private val avatars = arrayOf( + avatar, + itemView.findViewById(R.id.status_avatar_1), + itemView.findViewById(R.id.status_avatar_2) + ) + + fun setupWithConversation( + conversation: ConversationViewData, + payloads: Any? + ) { + val statusViewData = conversation.lastStatus + val status = statusViewData.status + + if (payloads == null) { + val account = status.account + + setupCollapsedState( + statusViewData.isCollapsible, + statusViewData.isCollapsed, + statusViewData.isExpanded, + status.spoilerText, + listener + ) + + setDisplayName(account.displayName!!, account.emojis, statusDisplayOptions) + setUsername(account.username) + setMetaData(statusViewData, statusDisplayOptions, listener) + setIsReply(status.inReplyToId != null) + setFavourited(status.favourited) + setBookmarked(status.bookmarked) + val attachments = status.attachments + val sensitive = status.sensitive + if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { + setMediaPreviews( + attachments, + sensitive, + listener, + statusViewData.isShowingContent, + statusDisplayOptions.useBlurhash + ) + + if (attachments.isEmpty()) { + hideSensitiveMediaWarning() + } + // Hide the unused label. + for (mediaLabel in mediaLabels) { + mediaLabel.isVisible = false + } + } else { + setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent) + // Hide all unused views. + mediaPreview.isVisible = false + hideSensitiveMediaWarning() + } + + setupButtons( + listener, + account.id, + statusViewData.content.toString(), + statusDisplayOptions + ) + + setSpoilerAndContent(statusViewData, statusDisplayOptions, listener) + + setConversationName(conversation.accounts) + + setAvatars(conversation.accounts) + } else { + if (payloads is List<*>) { + for (item in payloads) { + if (Key.KEY_CREATED == item) { + setMetaData(statusViewData, statusDisplayOptions, listener) + } + } + } + } + } + + private fun setConversationName(accounts: List) { + val context = conversationNameTextView.context + conversationNameTextView.text = when (accounts.size) { + 1 -> context.getString(R.string.conversation_1_recipients, accounts[0].username) + 2 -> context.getString( + R.string.conversation_2_recipients, + accounts[0].username, + accounts[1].username + ) + else -> context.getString( + R.string.conversation_more_recipients, + accounts[0].username, + accounts[1].username, + accounts.size - 2 + ) + } + } + + private fun setAvatars(accounts: List) { + for (i in avatars.indices) { + val avatarView = avatars[i] + avatarView.isVisible = if (i < accounts.size) { + loadAvatar( + accounts[i].avatar, + avatarView, + avatarRadius48dp, + statusDisplayOptions.animateAvatars, + null + ) + true + } else { + false + } + } + } + + private fun setupCollapsedState( + collapsible: Boolean, + collapsed: Boolean, + expanded: Boolean, + spoilerText: String, + listener: StatusActionListener + ) { + /* input filter for TextViews have to be set before text */ + if (collapsible && (expanded || spoilerText.isEmpty())) { + contentCollapseButton.setOnClickListener { _ -> + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onContentCollapsedChange(!collapsed, position) + } + } + + contentCollapseButton.isVisible = true + if (collapsed) { + contentCollapseButton.setText(R.string.post_content_warning_show_more) + content.filters = COLLAPSE_INPUT_FILTER + } else { + contentCollapseButton.setText(R.string.post_content_warning_show_less) + content.filters = NO_INPUT_FILTER + } + } else { + contentCollapseButton.isVisible = false + content.filters = NO_INPUT_FILTER + } + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index caa264d8f9..f9e7a0c27c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -286,9 +286,9 @@ class ConversationsFragment : } } - override fun onBookmark(favourite: Boolean, position: Int) { + override fun onBookmark(bookmark: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> - viewModel.bookmark(favourite, conversation) + viewModel.bookmark(bookmark, conversation) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 4eb95f2bb6..ebbe7b38ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -472,7 +472,7 @@ class TimelineFragment : viewModel.bookmark(bookmark, status) } - override fun onVoteInPoll(position: Int, choices: List) { + override fun onVoteInPoll(position: Int, choices: MutableList) { val status = adapter.peek(position)?.asStatusOrNull() ?: return viewModel.voteInPoll(choices, status) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index e38d7f3644..93919108eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -463,7 +463,7 @@ class ViewThreadFragment : } } - override fun onVoteInPoll(position: Int, choices: List) { + override fun onVoteInPoll(position: Int, choices: MutableList) { val status = adapter.currentList[position] viewModel.voteInPoll(choices, status) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java deleted file mode 100644 index 39261cb555..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ /dev/null @@ -1,701 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.db; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.room.AutoMigration; -import androidx.room.Database; -import androidx.room.DeleteColumn; -import androidx.room.RoomDatabase; -import androidx.room.migration.AutoMigrationSpec; -import androidx.room.migration.Migration; -import androidx.sqlite.db.SupportSQLiteDatabase; - -import com.keylesspalace.tusky.TabDataKt; -import com.keylesspalace.tusky.components.conversation.ConversationEntity; - -import java.io.File; - -/** - * DB version & declare DAO - */ -@Database( - entities = { - DraftEntity.class, - AccountEntity.class, - InstanceEntity.class, - TimelineStatusEntity.class, - TimelineAccountEntity.class, - ConversationEntity.class - }, - // Note: Starting with version 54, database versions in Tusky are always even. - // This is to reserve odd version numbers for use by forks. - version = 58, - autoMigrations = { - @AutoMigration(from = 48, to = 49), - @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), - @AutoMigration(from = 50, to = 51), - @AutoMigration(from = 51, to = 52), - @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity - @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity - } -) -public abstract class AppDatabase extends RoomDatabase { - - @NonNull public abstract AccountDao accountDao(); - @NonNull public abstract InstanceDao instanceDao(); - @NonNull public abstract ConversationsDao conversationDao(); - @NonNull public abstract TimelineDao timelineDao(); - @NonNull public abstract DraftDao draftDao(); - - public static final Migration MIGRATION_2_3 = new Migration(2, 3) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);"); - database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); - database.execSQL("DROP TABLE TootEntity;"); - database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); - } - }; - - public static final Migration MIGRATION_3_4 = new Migration(3, 4) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToId TEXT"); - database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToText TEXT"); - database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToUsername TEXT"); - database.execSQL("ALTER TABLE TootEntity ADD COLUMN visibility INTEGER"); - } - }; - - public static final Migration MIGRATION_4_5 = new Migration(4, 5) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("CREATE TABLE `AccountEntity` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, " + - "`isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, " + - "`username` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + - "`profilePictureUrl` TEXT NOT NULL, " + - "`notificationsEnabled` INTEGER NOT NULL, " + - "`notificationsMentioned` INTEGER NOT NULL, " + - "`notificationsFollowed` INTEGER NOT NULL, " + - "`notificationsReblogged` INTEGER NOT NULL, " + - "`notificationsFavorited` INTEGER NOT NULL, " + - "`notificationSound` INTEGER NOT NULL, " + - "`notificationVibration` INTEGER NOT NULL, " + - "`notificationLight` INTEGER NOT NULL, " + - "`lastNotificationId` TEXT NOT NULL, " + - "`activeNotifications` TEXT NOT NULL)"); - database.execSQL("CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)"); - } - }; - - public static final Migration MIGRATION_5_6 = new Migration(5, 6) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("CREATE TABLE IF NOT EXISTS `EmojiListEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`instance`))"); - } - }; - - public static final Migration MIGRATION_6_7 = new Migration(6, 7) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("CREATE TABLE IF NOT EXISTS `InstanceEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))"); - database.execSQL("INSERT OR REPLACE INTO `InstanceEntity` SELECT `instance`,`emojiList`, NULL FROM `EmojiListEntity`;"); - database.execSQL("DROP TABLE `EmojiListEntity`;"); - } - }; - - public static final Migration MIGRATION_7_8 = new Migration(7, 8) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `emojis` TEXT NOT NULL DEFAULT '[]'"); - } - }; - - public static final Migration MIGRATION_8_9 = new Migration(8, 9) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `descriptions` TEXT DEFAULT '[]'"); - } - }; - - public static final Migration MIGRATION_9_10 = new Migration(9, 10) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostPrivacy` INTEGER NOT NULL DEFAULT 1"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultMediaSensitivity` INTEGER NOT NULL DEFAULT 0"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysShowSensitiveMedia` INTEGER NOT NULL DEFAULT 0"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `mediaPreviewEnabled` INTEGER NOT NULL DEFAULT '1'"); - } - }; - - public static final Migration MIGRATION_10_11 = new Migration(10, 11) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + - "`serverId` TEXT NOT NULL, " + - "`timelineUserId` INTEGER NOT NULL, " + - "`instance` TEXT NOT NULL, " + - "`localUsername` TEXT NOT NULL, " + - "`username` TEXT NOT NULL, " + - "`displayName` TEXT NOT NULL, " + - "`url` TEXT NOT NULL, " + - "`avatar` TEXT NOT NULL, " + - "`emojis` TEXT NOT NULL," + - "PRIMARY KEY(`serverId`, `timelineUserId`))"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + - "`serverId` TEXT NOT NULL, " + - "`url` TEXT, " + - "`timelineUserId` INTEGER NOT NULL, " + - "`authorServerId` TEXT," + - "`instance` TEXT, " + - "`inReplyToId` TEXT, " + - "`inReplyToAccountId` TEXT, " + - "`content` TEXT, " + - "`createdAt` INTEGER NOT NULL, " + - "`emojis` TEXT, " + - "`reblogsCount` INTEGER NOT NULL, " + - "`favouritesCount` INTEGER NOT NULL, " + - "`reblogged` INTEGER NOT NULL, " + - "`favourited` INTEGER NOT NULL, " + - "`sensitive` INTEGER NOT NULL, " + - "`spoilerText` TEXT, " + - "`visibility` INTEGER, " + - "`attachments` TEXT, " + - "`mentions` TEXT, " + - "`application` TEXT, " + - "`reblogServerId` TEXT, " + - "`reblogAccountId` TEXT," + - " PRIMARY KEY(`serverId`, `timelineUserId`)," + - " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + - "ON UPDATE NO ACTION ON DELETE NO ACTION )"); - database.execSQL("CREATE INDEX IF NOT EXISTS" + - "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + - "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); - } - }; - - public static final Migration MIGRATION_11_12 = new Migration(11, 12) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - String defaultTabs = TabDataKt.HOME + ";" + - TabDataKt.NOTIFICATIONS + ";" + - TabDataKt.LOCAL + ";" + - TabDataKt.FEDERATED; - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '" + defaultTabs + "'"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + - "`accountId` INTEGER NOT NULL, " + - "`id` TEXT NOT NULL, " + - "`accounts` TEXT NOT NULL, " + - "`unread` INTEGER NOT NULL, " + - "`s_id` TEXT NOT NULL, " + - "`s_url` TEXT, " + - "`s_inReplyToId` TEXT, " + - "`s_inReplyToAccountId` TEXT, " + - "`s_account` TEXT NOT NULL, " + - "`s_content` TEXT NOT NULL, " + - "`s_createdAt` INTEGER NOT NULL, " + - "`s_emojis` TEXT NOT NULL, " + - "`s_favouritesCount` INTEGER NOT NULL, " + - "`s_favourited` INTEGER NOT NULL, " + - "`s_sensitive` INTEGER NOT NULL, " + - "`s_spoilerText` TEXT NOT NULL, " + - "`s_attachments` TEXT NOT NULL, " + - "`s_mentions` TEXT NOT NULL, " + - "`s_showingHiddenContent` INTEGER NOT NULL, " + - "`s_expanded` INTEGER NOT NULL, " + - "`s_collapsible` INTEGER NOT NULL, " + - "`s_collapsed` INTEGER NOT NULL, " + - "PRIMARY KEY(`id`, `accountId`))"); - - } - }; - - public static final Migration MIGRATION_12_13 = new Migration(12, 13) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - - database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); - database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + - "`serverId` TEXT NOT NULL, " + - "`timelineUserId` INTEGER NOT NULL, " + - "`localUsername` TEXT NOT NULL, " + - "`username` TEXT NOT NULL, " + - "`displayName` TEXT NOT NULL, " + - "`url` TEXT NOT NULL, " + - "`avatar` TEXT NOT NULL, " + - "`emojis` TEXT NOT NULL," + - "PRIMARY KEY(`serverId`, `timelineUserId`))"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + - "`serverId` TEXT NOT NULL, " + - "`url` TEXT, " + - "`timelineUserId` INTEGER NOT NULL, " + - "`authorServerId` TEXT," + - "`inReplyToId` TEXT, " + - "`inReplyToAccountId` TEXT, " + - "`content` TEXT, " + - "`createdAt` INTEGER NOT NULL, " + - "`emojis` TEXT, " + - "`reblogsCount` INTEGER NOT NULL, " + - "`favouritesCount` INTEGER NOT NULL, " + - "`reblogged` INTEGER NOT NULL, " + - "`favourited` INTEGER NOT NULL, " + - "`sensitive` INTEGER NOT NULL, " + - "`spoilerText` TEXT, " + - "`visibility` INTEGER, " + - "`attachments` TEXT, " + - "`mentions` TEXT, " + - "`application` TEXT, " + - "`reblogServerId` TEXT, " + - "`reblogAccountId` TEXT," + - " PRIMARY KEY(`serverId`, `timelineUserId`)," + - " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + - "ON UPDATE NO ACTION ON DELETE NO ACTION )"); - database.execSQL("CREATE INDEX IF NOT EXISTS" + - "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + - "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); - } - }; - - public static final Migration MIGRATION_10_13 = new Migration(10, 13) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - MIGRATION_11_12.migrate(database); - MIGRATION_12_13.migrate(database); - } - }; - - public static final Migration MIGRATION_13_14 = new Migration(13, 14) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); - } - }; - - public static final Migration MIGRATION_14_15 = new Migration(14, 15) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT"); - } - }; - - public static final Migration MIGRATION_15_16 = new Migration(15, 16) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsPolls` INTEGER NOT NULL DEFAULT 1"); - } - }; - - public static final Migration MIGRATION_16_17 = new Migration(16, 17) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineAccountEntity` ADD COLUMN `bot` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_17_18 = new Migration(17, 18) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_18_19 = new Migration(18, 19) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptions` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptionLength` INTEGER"); - - database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `poll` TEXT"); - } - }; - - public static final Migration MIGRATION_19_20 = new Migration(19, 20) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0"); - } - - }; - - public static final Migration MIGRATION_20_21 = new Migration(20, 21) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT"); - } - }; - - public static final Migration MIGRATION_21_22 = new Migration(21, 22) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_22_23 = new Migration(22, 23) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER"); - } - }; - - public static final Migration MIGRATION_23_24 = new Migration(23, 24) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSubscriptions` INTEGER NOT NULL DEFAULT 1"); - } - }; - - public static final Migration MIGRATION_24_25 = new Migration(24, 25) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL( - "CREATE TABLE IF NOT EXISTS `DraftEntity` (" + - "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + - "`accountId` INTEGER NOT NULL, " + - "`inReplyToId` TEXT," + - "`content` TEXT," + - "`contentWarning` TEXT," + - "`sensitive` INTEGER NOT NULL," + - "`visibility` INTEGER NOT NULL," + - "`attachments` TEXT NOT NULL," + - "`poll` TEXT," + - "`failedToSend` INTEGER NOT NULL)" - ); - } - }; - - public static class Migration25_26 extends Migration { - - private final File oldDraftDirectory; - - public Migration25_26(@Nullable File oldDraftDirectory) { - super(25, 26); - this.oldDraftDirectory = oldDraftDirectory; - } - - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("DROP TABLE `TootEntity`"); - - if (oldDraftDirectory != null && oldDraftDirectory.isDirectory()) { - File[] oldDraftFiles = oldDraftDirectory.listFiles(); - if (oldDraftFiles != null) { - for (File file : oldDraftFiles) { - if (!file.isDirectory()) { - file.delete(); - } - } - } - - } - } - } - - public static final Migration MIGRATION_26_27 = new Migration(26, 27) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_27_28 = new Migration(27, 28) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - - database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); - database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + - "`serverId` TEXT NOT NULL," + - "`timelineUserId` INTEGER NOT NULL," + - "`localUsername` TEXT NOT NULL," + - "`username` TEXT NOT NULL," + - "`displayName` TEXT NOT NULL," + - "`url` TEXT NOT NULL," + - "`avatar` TEXT NOT NULL," + - "`emojis` TEXT NOT NULL," + - "`bot` INTEGER NOT NULL," + - "PRIMARY KEY(`serverId`, `timelineUserId`) )"); - - database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + - "`serverId` TEXT NOT NULL," + - "`url` TEXT," + - "`timelineUserId` INTEGER NOT NULL," + - "`authorServerId` TEXT," + - "`inReplyToId` TEXT," + - "`inReplyToAccountId` TEXT," + - "`content` TEXT," + - "`createdAt` INTEGER NOT NULL," + - "`emojis` TEXT," + - "`reblogsCount` INTEGER NOT NULL," + - "`favouritesCount` INTEGER NOT NULL," + - "`reblogged` INTEGER NOT NULL," + - "`bookmarked` INTEGER NOT NULL," + - "`favourited` INTEGER NOT NULL," + - "`sensitive` INTEGER NOT NULL," + - "`spoilerText` TEXT NOT NULL," + - "`visibility` INTEGER NOT NULL," + - "`attachments` TEXT," + - "`mentions` TEXT," + - "`application` TEXT," + - "`reblogServerId` TEXT," + - "`reblogAccountId` TEXT," + - "`poll` TEXT," + - "`muted` INTEGER," + - "`expanded` INTEGER NOT NULL," + - "`contentCollapsed` INTEGER NOT NULL," + - "`contentShowing` INTEGER NOT NULL," + - "`pinned` INTEGER NOT NULL," + - "PRIMARY KEY(`serverId`, `timelineUserId`)," + - "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + - "ON UPDATE NO ACTION ON DELETE NO ACTION )"); - - database.execSQL("CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + - "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); - } - }; - - public static final Migration MIGRATION_28_29 = new Migration(28, 29) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_tags` TEXT"); - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `tags` TEXT"); - } - }; - - public static final Migration MIGRATION_29_30 = new Migration(29, 30) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `charactersReservedPerUrl` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `minPollDuration` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER"); - } - }; - - public static final Migration MIGRATION_30_31 = new Migration(30, 31) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - - // no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs - database.execSQL("DELETE FROM `TimelineAccountEntity`"); - database.execSQL("DELETE FROM `TimelineStatusEntity`"); - } - }; - - public static final Migration MIGRATION_31_32 = new Migration(31, 32) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1"); - } - }; - - public static final Migration MIGRATION_32_33 = new Migration(32, 33) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - - // ConversationEntity lost the s_collapsible column - // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table. - database.execSQL("DROP TABLE `ConversationEntity`"); - database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + - "`accountId` INTEGER NOT NULL," + - "`id` TEXT NOT NULL," + - "`accounts` TEXT NOT NULL," + - "`unread` INTEGER NOT NULL," + - "`s_id` TEXT NOT NULL," + - "`s_url` TEXT," + - "`s_inReplyToId` TEXT," + - "`s_inReplyToAccountId` TEXT," + - "`s_account` TEXT NOT NULL," + - "`s_content` TEXT NOT NULL," + - "`s_createdAt` INTEGER NOT NULL," + - "`s_emojis` TEXT NOT NULL," + - "`s_favouritesCount` INTEGER NOT NULL," + - "`s_favourited` INTEGER NOT NULL," + - "`s_bookmarked` INTEGER NOT NULL," + - "`s_sensitive` INTEGER NOT NULL," + - "`s_spoilerText` TEXT NOT NULL," + - "`s_attachments` TEXT NOT NULL," + - "`s_mentions` TEXT NOT NULL," + - "`s_tags` TEXT," + - "`s_showingHiddenContent` INTEGER NOT NULL," + - "`s_expanded` INTEGER NOT NULL," + - "`s_collapsed` INTEGER NOT NULL," + - "`s_muted` INTEGER NOT NULL," + - "`s_poll` TEXT," + - "PRIMARY KEY(`id`, `accountId`))"); - } - }; - - public static final Migration MIGRATION_33_34 = new Migration(33, 34) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1"); - } - }; - - public static final Migration MIGRATION_34_35 = new Migration(34, 35) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT"); - } - }; - - public static final Migration MIGRATION_35_36 = new Migration(35, 36) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); - } - }; - - public static final Migration MIGRATION_36_37 = new Migration(36, 37) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_37_38 = new Migration(37, 38) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - // database needs to be cleaned because the ConversationAccountEntity got a new attribute - database.execSQL("DELETE FROM `ConversationEntity`"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0"); - - // timestamps are now serialized differently so all cache tables that contain them need to be cleaned - database.execSQL("DELETE FROM `TimelineStatusEntity`"); - } - }; - - public static final Migration MIGRATION_38_39 = new Migration(38, 39) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT"); - } - }; - - public static final Migration MIGRATION_39_40 = new Migration(39, 40) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER"); - database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER"); - } - }; - - public static final Migration MIGRATION_40_41 = new Migration(40, 41) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT"); - } - }; - - public static final Migration MIGRATION_41_42 = new Migration(41, 42) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT"); - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT"); - } - }; - - public static final Migration MIGRATION_42_43 = new Migration(42, 43) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''"); - } - }; - - public static final Migration MIGRATION_43_44 = new Migration(43, 44) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1"); - } - }; - - public static final Migration MIGRATION_44_45 = new Migration(44, 45) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `editedAt` INTEGER"); - database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER"); - } - }; - - public static final Migration MIGRATION_45_46 = new Migration(45, 46) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `statusId` TEXT"); - } - }; - - public static final Migration MIGRATION_46_47 = new Migration(46, 47) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_47_48 = new Migration(47, 48) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); - } - }; - - @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") - static class MIGRATION_49_50 implements AutoMigrationSpec { } - - /** - * TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text - * representation was changed from "Trending" to "TrendingTags". - */ - public static final Migration MIGRATION_52_53 = new Migration(52, 53) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')"); - } - }; - - public static final Migration MIGRATION_54_56 = new Migration(54, 56) { - @Override - public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeBoosts` INTEGER NOT NULL DEFAULT 1"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeReplies` INTEGER NOT NULL DEFAULT 1"); - database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1"); - } - }; -} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.kt b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.kt new file mode 100644 index 0000000000..94706d2d2f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.kt @@ -0,0 +1,663 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +@file:Suppress("ClassName") + +package com.keylesspalace.tusky.db + +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteColumn +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.keylesspalace.tusky.FEDERATED +import com.keylesspalace.tusky.HOME +import com.keylesspalace.tusky.LOCAL +import com.keylesspalace.tusky.NOTIFICATIONS +import com.keylesspalace.tusky.components.conversation.ConversationEntity +import com.keylesspalace.tusky.db.AppDatabase.MIGRATION_49_50 +import java.io.File + +/** + * DB version & declare DAO + */ +@Database( + entities = [ + DraftEntity::class, + AccountEntity::class, + InstanceEntity::class, + TimelineStatusEntity::class, + TimelineAccountEntity::class, + ConversationEntity::class, + ], + version = 58, + autoMigrations = [ + AutoMigration(from = 48, to = 49), + AutoMigration(from = 49, to = 50, spec = MIGRATION_49_50::class), + AutoMigration(from = 50, to = 51), + AutoMigration(from = 51, to = 52), + AutoMigration(from = 53, to = 54), + AutoMigration(from = 56, to = 58), + ] +) +abstract class AppDatabase : RoomDatabase() { + abstract fun accountDao(): AccountDao + abstract fun instanceDao(): InstanceDao + abstract fun conversationDao(): ConversationsDao + abstract fun timelineDao(): TimelineDao + abstract fun draftDao(): DraftDao + + class Migration25_26(private val oldDraftDirectory: File?) : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE `TootEntity`") + + if (oldDraftDirectory != null && oldDraftDirectory.isDirectory) { + val oldDraftFiles = oldDraftDirectory.listFiles() + if (oldDraftFiles != null) { + for (file in oldDraftFiles) { + if (!file.isDirectory) { + file.delete() + } + } + } + } + } + } + + @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") + class MIGRATION_49_50 : AutoMigrationSpec + + companion object { + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);") + db.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;") + db.execSQL("DROP TABLE TootEntity;") + db.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;") + } + } + + val MIGRATION_3_4: Migration = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToId TEXT") + db.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToText TEXT") + db.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToUsername TEXT") + db.execSQL("ALTER TABLE TootEntity ADD COLUMN visibility INTEGER") + } + } + + val MIGRATION_4_5: Migration = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE `AccountEntity` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, " + + "`isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + + "`profilePictureUrl` TEXT NOT NULL, " + + "`notificationsEnabled` INTEGER NOT NULL, " + + "`notificationsMentioned` INTEGER NOT NULL, " + + "`notificationsFollowed` INTEGER NOT NULL, " + + "`notificationsReblogged` INTEGER NOT NULL, " + + "`notificationsFavorited` INTEGER NOT NULL, " + + "`notificationSound` INTEGER NOT NULL, " + + "`notificationVibration` INTEGER NOT NULL, " + + "`notificationLight` INTEGER NOT NULL, " + + "`lastNotificationId` TEXT NOT NULL, " + + "`activeNotifications` TEXT NOT NULL)" + ) + db.execSQL("CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)") + } + } + + val MIGRATION_5_6: Migration = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `EmojiListEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`instance`))") + } + } + + val MIGRATION_6_7: Migration = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `InstanceEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))") + db.execSQL("INSERT OR REPLACE INTO `InstanceEntity` SELECT `instance`,`emojiList`, NULL FROM `EmojiListEntity`;") + db.execSQL("DROP TABLE `EmojiListEntity`;") + } + } + + val MIGRATION_7_8: Migration = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `emojis` TEXT NOT NULL DEFAULT '[]'") + } + } + + val MIGRATION_8_9: Migration = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `descriptions` TEXT DEFAULT '[]'") + } + } + + val MIGRATION_9_10: Migration = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostPrivacy` INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultMediaSensitivity` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysShowSensitiveMedia` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `mediaPreviewEnabled` INTEGER NOT NULL DEFAULT '1'") + } + } + + val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`instance` TEXT NOT NULL, " + + "`localUsername` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, " + + "`displayName` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`avatar` TEXT NOT NULL, " + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`))" + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`url` TEXT, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`authorServerId` TEXT," + + "`instance` TEXT, " + + "`inReplyToId` TEXT, " + + "`inReplyToAccountId` TEXT, " + + "`content` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`emojis` TEXT, " + + "`reblogsCount` INTEGER NOT NULL, " + + "`favouritesCount` INTEGER NOT NULL, " + + "`reblogged` INTEGER NOT NULL, " + + "`favourited` INTEGER NOT NULL, " + + "`sensitive` INTEGER NOT NULL, " + + "`spoilerText` TEXT, " + + "`visibility` INTEGER, " + + "`attachments` TEXT, " + + "`mentions` TEXT, " + + "`application` TEXT, " + + "`reblogServerId` TEXT, " + + "`reblogAccountId` TEXT," + + " PRIMARY KEY(`serverId`, `timelineUserId`)," + + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS" + + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)" + ) + } + } + + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + val defaultTabs: String = HOME + ";" + + NOTIFICATIONS + ";" + + LOCAL + ";" + + FEDERATED + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '$defaultTabs'") + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`accountId` INTEGER NOT NULL, " + + "`id` TEXT NOT NULL, " + + "`accounts` TEXT NOT NULL, " + + "`unread` INTEGER NOT NULL, " + + "`s_id` TEXT NOT NULL, " + + "`s_url` TEXT, " + + "`s_inReplyToId` TEXT, " + + "`s_inReplyToAccountId` TEXT, " + + "`s_account` TEXT NOT NULL, " + + "`s_content` TEXT NOT NULL, " + + "`s_createdAt` INTEGER NOT NULL, " + + "`s_emojis` TEXT NOT NULL, " + + "`s_favouritesCount` INTEGER NOT NULL, " + + "`s_favourited` INTEGER NOT NULL, " + + "`s_sensitive` INTEGER NOT NULL, " + + "`s_spoilerText` TEXT NOT NULL, " + + "`s_attachments` TEXT NOT NULL, " + + "`s_mentions` TEXT NOT NULL, " + + "`s_showingHiddenContent` INTEGER NOT NULL, " + + "`s_expanded` INTEGER NOT NULL, " + + "`s_collapsible` INTEGER NOT NULL, " + + "`s_collapsed` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`, `accountId`))" + ) + } + } + + val MIGRATION_12_13: Migration = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`") + db.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`") + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`localUsername` TEXT NOT NULL, " + + "`username` TEXT NOT NULL, " + + "`displayName` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`avatar` TEXT NOT NULL, " + + "`emojis` TEXT NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`))" + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL, " + + "`url` TEXT, " + + "`timelineUserId` INTEGER NOT NULL, " + + "`authorServerId` TEXT," + + "`inReplyToId` TEXT, " + + "`inReplyToAccountId` TEXT, " + + "`content` TEXT, " + + "`createdAt` INTEGER NOT NULL, " + + "`emojis` TEXT, " + + "`reblogsCount` INTEGER NOT NULL, " + + "`favouritesCount` INTEGER NOT NULL, " + + "`reblogged` INTEGER NOT NULL, " + + "`favourited` INTEGER NOT NULL, " + + "`sensitive` INTEGER NOT NULL, " + + "`spoilerText` TEXT, " + + "`visibility` INTEGER, " + + "`attachments` TEXT, " + + "`mentions` TEXT, " + + "`application` TEXT, " + + "`reblogServerId` TEXT, " + + "`reblogAccountId` TEXT," + + " PRIMARY KEY(`serverId`, `timelineUserId`)," + + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS" + + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)" + ) + } + } + + val MIGRATION_10_13: Migration = object : Migration(10, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + MIGRATION_11_12.migrate(db) + MIGRATION_12_13.migrate(db) + } + } + + val MIGRATION_13_14: Migration = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'") + } + } + + val MIGRATION_14_15: Migration = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT") + } + } + + val MIGRATION_15_16: Migration = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsPolls` INTEGER NOT NULL DEFAULT 1") + } + } + + val MIGRATION_16_17: Migration = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineAccountEntity` ADD COLUMN `bot` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_17_18: Migration = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_18_19: Migration = object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptions` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptionLength` INTEGER") + + db.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `poll` TEXT") + } + } + + val MIGRATION_19_20: Migration = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_20_21: Migration = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT") + } + } + + val MIGRATION_21_22: Migration = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_22_23: Migration = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER") + } + } + + val MIGRATION_23_24: Migration = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSubscriptions` INTEGER NOT NULL DEFAULT 1") + } + } + + val MIGRATION_24_25: Migration = object : Migration(24, 25) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `DraftEntity` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`accountId` INTEGER NOT NULL, " + + "`inReplyToId` TEXT," + + "`content` TEXT," + + "`contentWarning` TEXT," + + "`sensitive` INTEGER NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT NOT NULL," + + "`poll` TEXT," + + "`failedToSend` INTEGER NOT NULL)" + ) + } + } + + val MIGRATION_26_27: Migration = object : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_27_28: Migration = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`") + db.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`") + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL," + + "`timelineUserId` INTEGER NOT NULL," + + "`localUsername` TEXT NOT NULL," + + "`username` TEXT NOT NULL," + + "`displayName` TEXT NOT NULL," + + "`url` TEXT NOT NULL," + + "`avatar` TEXT NOT NULL," + + "`emojis` TEXT NOT NULL," + + "`bot` INTEGER NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`) )" + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL," + + "`url` TEXT," + + "`timelineUserId` INTEGER NOT NULL," + + "`authorServerId` TEXT," + + "`inReplyToId` TEXT," + + "`inReplyToAccountId` TEXT," + + "`content` TEXT," + + "`createdAt` INTEGER NOT NULL," + + "`emojis` TEXT," + + "`reblogsCount` INTEGER NOT NULL," + + "`favouritesCount` INTEGER NOT NULL," + + "`reblogged` INTEGER NOT NULL," + + "`bookmarked` INTEGER NOT NULL," + + "`favourited` INTEGER NOT NULL," + + "`sensitive` INTEGER NOT NULL," + + "`spoilerText` TEXT NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT," + + "`mentions` TEXT," + + "`application` TEXT," + + "`reblogServerId` TEXT," + + "`reblogAccountId` TEXT," + + "`poll` TEXT," + + "`muted` INTEGER," + + "`expanded` INTEGER NOT NULL," + + "`contentCollapsed` INTEGER NOT NULL," + + "`contentShowing` INTEGER NOT NULL," + + "`pinned` INTEGER NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`)," + + "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + + "ON UPDATE NO ACTION ON DELETE NO ACTION )" + ) + + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)" + ) + } + } + + val MIGRATION_28_29: Migration = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_tags` TEXT") + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `tags` TEXT") + } + } + + val MIGRATION_29_30: Migration = object : Migration(29, 30) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `charactersReservedPerUrl` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `minPollDuration` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER") + } + } + + val MIGRATION_30_31: Migration = object : Migration(30, 31) { + override fun migrate(db: SupportSQLiteDatabase) { + // no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs + + db.execSQL("DELETE FROM `TimelineAccountEntity`") + db.execSQL("DELETE FROM `TimelineStatusEntity`") + } + } + + val MIGRATION_31_32: Migration = object : Migration(31, 32) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1") + } + } + + val MIGRATION_32_33: Migration = object : Migration(32, 33) { + override fun migrate(db: SupportSQLiteDatabase) { + // ConversationEntity lost the s_collapsible column + // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table. + + db.execSQL("DROP TABLE `ConversationEntity`") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`accountId` INTEGER NOT NULL," + + "`id` TEXT NOT NULL," + + "`accounts` TEXT NOT NULL," + + "`unread` INTEGER NOT NULL," + + "`s_id` TEXT NOT NULL," + + "`s_url` TEXT," + + "`s_inReplyToId` TEXT," + + "`s_inReplyToAccountId` TEXT," + + "`s_account` TEXT NOT NULL," + + "`s_content` TEXT NOT NULL," + + "`s_createdAt` INTEGER NOT NULL," + + "`s_emojis` TEXT NOT NULL," + + "`s_favouritesCount` INTEGER NOT NULL," + + "`s_favourited` INTEGER NOT NULL," + + "`s_bookmarked` INTEGER NOT NULL," + + "`s_sensitive` INTEGER NOT NULL," + + "`s_spoilerText` TEXT NOT NULL," + + "`s_attachments` TEXT NOT NULL," + + "`s_mentions` TEXT NOT NULL," + + "`s_tags` TEXT," + + "`s_showingHiddenContent` INTEGER NOT NULL," + + "`s_expanded` INTEGER NOT NULL," + + "`s_collapsed` INTEGER NOT NULL," + + "`s_muted` INTEGER NOT NULL," + + "`s_poll` TEXT," + + "PRIMARY KEY(`id`, `accountId`))" + ) + } + } + + val MIGRATION_33_34: Migration = object : Migration(33, 34) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1") + } + } + + val MIGRATION_34_35: Migration = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT") + } + } + + val MIGRATION_35_36: Migration = object : Migration(35, 36) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''") + } + } + + val MIGRATION_36_37: Migration = object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_37_38: Migration = object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + // database needs to be cleaned because the ConversationAccountEntity got a new attribute + db.execSQL("DELETE FROM `ConversationEntity`") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0") + + // timestamps are now serialized differently so all cache tables that contain them need to be cleaned + db.execSQL("DELETE FROM `TimelineStatusEntity`") + } + } + + val MIGRATION_38_39: Migration = object : Migration(38, 39) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT") + } + } + + val MIGRATION_39_40: Migration = object : Migration(39, 40) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER") + db.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER") + } + } + + val MIGRATION_40_41: Migration = object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT") + } + } + + val MIGRATION_41_42: Migration = object : Migration(41, 42) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT") + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT") + } + } + + val MIGRATION_42_43: Migration = object : Migration(42, 43) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''") + } + } + + val MIGRATION_43_44: Migration = object : Migration(43, 44) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1") + } + } + + val MIGRATION_44_45: Migration = object : Migration(44, 45) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `editedAt` INTEGER") + db.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER") + } + } + + val MIGRATION_45_46: Migration = object : Migration(45, 46) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `statusId` TEXT") + } + } + + val MIGRATION_46_47: Migration = object : Migration(46, 47) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0") + } + } + + val MIGRATION_47_48: Migration = object : Migration(47, 48) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT") + } + } + + /** + * TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text + * representation was changed from "Trending" to "TrendingTags". + */ + val MIGRATION_52_53: Migration = object : Migration(52, 53) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')") + } + } + + val MIGRATION_54_56: Migration = object : Migration(54, 56) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeBoosts` INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeReplies` INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1") + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.kt similarity index 73% rename from app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java rename to app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.kt index 089331a1cc..68d1ebc9b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.kt @@ -12,15 +12,13 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.interfaces -package com.keylesspalace.tusky.interfaces; +import com.google.android.material.floatingactionbutton.FloatingActionButton -import androidx.annotation.Nullable; -import com.google.android.material.floatingactionbutton.FloatingActionButton; - -public interface ActionButtonActivity { - - /* return the ActionButton of the Activity to hide or show it on scroll */ - @Nullable - FloatingActionButton getActionButton(); +interface ActionButtonActivity { + /** + * return the ActionButton of the Activity to hide or show it on scroll + */ + val actionButton: FloatingActionButton? } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java rename to app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt index 75f6ed7490..955762ad2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt @@ -12,60 +12,55 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.interfaces -package com.keylesspalace.tusky.interfaces; +import android.view.View -import android.view.View; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public interface StatusActionListener extends LinkListener { - void onReply(int position); - void onReblog(final boolean reblog, final int position); - void onFavourite(final boolean favourite, final int position); - void onBookmark(final boolean bookmark, final int position); - void onMore(@NonNull View view, final int position); - void onViewMedia(int position, int attachmentIndex, @Nullable View view); - void onViewThread(int position); +@JvmDefaultWithCompatibility +interface StatusActionListener : LinkListener { + fun onReply(position: Int) + fun onReblog(reblog: Boolean, position: Int) + fun onFavourite(favourite: Boolean, position: Int) + fun onBookmark(bookmark: Boolean, position: Int) + fun onMore(view: View, position: Int) + fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) + fun onViewThread(position: Int) /** * Open reblog author for the status. * @param position At which position in the list status is located */ - void onOpenReblog(int position); - void onExpandedChange(boolean expanded, int position); - void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); + fun onOpenReblog(position: Int) + fun onExpandedChange(expanded: Boolean, position: Int) + fun onContentHiddenChange(isShowing: Boolean, position: Int) + fun onLoadMore(position: Int) /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * Called when the status [android.widget.ToggleButton] responsible for collapsing long * status content is interacted with. * * @param isCollapsed Whether the status content is shown in a collapsed state or fully. * @param position The position of the status in the list. */ - void onContentCollapsedChange(boolean isCollapsed, int position); + fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) /** * called when the reblog count has been clicked * @param position The position of the status in the list. */ - default void onShowReblogs(int position) {} + fun onShowReblogs(position: Int) {} /** * called when the favourite count has been clicked * @param position The position of the status in the list. */ - default void onShowFavs(int position) {} + fun onShowFavs(position: Int) {} - void onVoteInPoll(int position, @NonNull List choices); + fun onVoteInPoll(position: Int, choices: MutableList) - default void onShowEdits(int position) {} + fun onShowEdits(position: Int) {} - void clearWarningAction(int position); + fun clearWarningAction(position: Int) - void onUntranslate(int position); + fun onUntranslate(position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc71e..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.viewdata; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; - -import java.util.Objects; - -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * It is either a {@link Placeholder} or a {@link Concrete}. - * It is modelled this way because close relationship between placeholder and concrete notification - * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { - } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 0000000000..1fab4a8d34 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,80 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.viewdata + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.viewdata.NotificationViewData.Concrete +import com.keylesspalace.tusky.viewdata.NotificationViewData.Placeholder +import java.util.Objects + +/** + * Created by charlag on 12/07/2017. + * + * + * Class to represent data required to display either a notification or a placeholder. + * It is either a [Placeholder] or a [Concrete]. + * It is modelled this way because close relationship between placeholder and concrete notification + * is fine in this case. Placeholder case is not modelled as a type of notification because + * invariants would be violated and because it would model domain incorrectly. It is preferable to + * [com.keylesspalace.tusky.util.Either] because class hierarchy is cheaper, faster and + * more native. + */ +abstract class NotificationViewData private constructor() { + abstract val viewDataId: Long + + abstract fun deepEquals(other: NotificationViewData?): Boolean + + class Concrete( + val type: Notification.Type, + val id: String, + val account: TimelineAccount, + val statusViewData: StatusViewData.Concrete?, + val report: Report? + ) : NotificationViewData() { + + override val viewDataId: Long = id.hashCode().toLong() + + override fun deepEquals(other: NotificationViewData?): Boolean { + if (this == other) return true + if (other == null || javaClass != other.javaClass) return false + val concrete = other as Concrete + return type == concrete.type && id == concrete.id && account.id == concrete.account.id && + (statusViewData == concrete.statusViewData) && + (report == concrete.report) + } + + override fun hashCode(): Int { + return Objects.hash(type, id, account, statusViewData) + } + + fun copyWithStatus(statusViewData: StatusViewData.Concrete?): Concrete { + return Concrete(type, id, account, statusViewData, report) + } + } + + class Placeholder( + private val id: Long, + val isLoading: Boolean + ) : NotificationViewData() { + override val viewDataId: Long = id + + override fun deepEquals(other: NotificationViewData?): Boolean { + if (other !is Placeholder) return false + return isLoading == other.isLoading && id == other.id + } + } +}