ComposeActivity improvements (#548)

* do not add media urls to status text

* add scrolling to content

* add arrow icon and animation to replying-to toggle

* remove unnecessary compose_button_colors.xml

* improve toot button

* improve bottom bar, add bottom sheet for compose options, dedicated cw button

* fix crash on Android < API 21

* move media picking from dialog to bottom sheet

* add small style tootbutton

* fix colors/button background for light theme

* add icons to media chose bottom sheet

* improve hide media button, delete unused styles

* fix crash on dev build when taking photo

* consolidate drawables

* consolidate strings and ids, add tooltips to buttons

* allow media only toots

* change error message to show max size of upload correctly

* fix button color

* add emoji

* code cleanup

* Merge branch 'master' into compose_activity_refactoring

# Conflicts:
#	app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java

* fix hidden snackbar

* improve hint text color

* add SendTootService

* fix timeline refreshing

* toot saving and error handling for sendtootservice

* restructure some code

* convert EditTextTyped to Kotlin

* fixed pick media button disabled color

* force sensitive media when content warning is shown

* add db cache for emojis & fix tests

* reorder buttons to match mastodon web

* add possibility to cancel sending of toot

* correctly delete sent toots

* refresh SavedTootActivity after toot was sent

* remove unused resources

* correct params for toot saving in SendTootService

* consolidate strings

* bugfix

* remove unused resources

* fix notifications on old android for SendTootService

* fix crash
This commit is contained in:
Konrad Pozniak 2018-04-13 22:37:21 +02:00 committed by GitHub
commit 27eefbf65a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 1815 additions and 1234 deletions

View file

@ -7,7 +7,6 @@ import android.support.design.widget.Snackbar;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.Toolbar;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

View file

@ -29,7 +29,6 @@ import android.support.design.widget.TabLayout;
import android.support.graphics.drawable.VectorDrawableCompat;
import android.support.v4.app.Fragment;
import android.support.v4.content.ContextCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AlertDialog;
import android.util.Log;
@ -43,7 +42,6 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.pager.TimelinePagerAdapter;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.NotificationHelper;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.mikepenz.google_material_typeface_library.GoogleMaterial;
@ -242,16 +240,6 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity,
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == COMPOSE_RESULT && resultCode == ComposeActivity.RESULT_OK) {
Intent intent = new Intent(TimelineReceiver.Types.STATUS_COMPOSED);
LocalBroadcastManager.getInstance(getApplicationContext())
.sendBroadcast(intent);
}
super.onActivityResult(requestCode, resultCode, data);
}
@Override
public void onBackPressed() {
if (drawer != null && drawer.isDrawerOpen()) {

View file

@ -15,27 +15,29 @@
package com.keylesspalace.tusky;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.lang.ref.WeakReference;
@ -43,11 +45,12 @@ import java.util.ArrayList;
import java.util.List;
public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction {
private static final String TAG = "SavedTootActivity"; // logging tag
// dao
private static TootDao tootDao = TuskyApplication.getDB().tootDao();
private SaveTootHelper saveTootHelper;
// ui
private SavedTootAdapter adapter;
private TextView noContent;
@ -55,9 +58,27 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
private List<TootEntity> toots = new ArrayList<>();
@Nullable private AsyncTask<?, ?, ?> asyncTask;
private BroadcastReceiver broadcastReceiver;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
saveTootHelper = new SaveTootHelper(tootDao, this);
broadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
fetchToots();
}
};
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(TimelineReceiver.Types.STATUS_COMPOSED);
LocalBroadcastManager.getInstance(this)
.registerReceiver(broadcastReceiver, intentFilter);
setContentView(R.layout.activity_saved_toot);
Toolbar toolbar = findViewById(R.id.toolbar);
@ -96,6 +117,12 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
if (asyncTask != null) asyncTask.cancel(true);
}
@Override
protected void onDestroy() {
super.onDestroy();
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
@ -122,19 +149,9 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
@Override
public void delete(int position, TootEntity item) {
// Delete any media files associated with the status.
ArrayList<String> uris = new Gson().fromJson(item.getUrls(),
new TypeToken<ArrayList<String>>() {}.getType());
if (uris != null) {
for (String uriString : uris) {
Uri uri = Uri.parse(uriString);
if (getContentResolver().delete(uri, null, null) == 0) {
Log.e(TAG, String.format("Did not delete file %s.", uriString));
}
}
}
// update DB
tootDao.delete(item.getUid());
saveTootHelper.deleteDraft(item);
toots.remove(position);
// update adapter
if (adapter != null) {

View file

@ -17,6 +17,7 @@ package com.keylesspalace.tusky;
import android.app.Activity;
import android.app.Application;
import android.app.Service;
import android.app.UiModeManager;
import android.arch.persistence.room.Room;
import android.content.Context;
@ -39,10 +40,11 @@ import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasActivityInjector;
import dagger.android.HasServiceInjector;
import okhttp3.Cache;
import okhttp3.OkHttpClient;
public class TuskyApplication extends Application implements HasActivityInjector {
public class TuskyApplication extends Application implements HasActivityInjector, HasServiceInjector {
public static final String APP_THEME_DEFAULT = ThemeUtils.THEME_NIGHT;
private static AppDatabase db;
@ -50,6 +52,8 @@ public class TuskyApplication extends Application implements HasActivityInjector
@Inject
DispatchingAndroidInjector<Activity> dispatchingAndroidInjector;
@Inject
DispatchingAndroidInjector<Service> dispatchingServiceInjector;
@Inject
NotificationPullJobCreator notificationPullJobCreator;
public static AppDatabase getDB() {
@ -75,7 +79,7 @@ public class TuskyApplication extends Application implements HasActivityInjector
db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "tuskyDB")
.allowMainThreadQueries()
.addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5)
.addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6)
.build();
accountManager = new AccountManager(db);
serviceLocator = new ServiceLocator() {
@ -90,7 +94,7 @@ public class TuskyApplication extends Application implements HasActivityInjector
}
};
AppInjector.INSTANCE.init(this);
initAppInjector();
initPicasso();
JobManager.create(this).addJobCreator(notificationPullJobCreator);
@ -100,6 +104,10 @@ public class TuskyApplication extends Application implements HasActivityInjector
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
}
protected void initAppInjector() {
AppInjector.INSTANCE.init(this);
}
protected void initPicasso() {
// Initialize Picasso configuration
Picasso.Builder builder = new Picasso.Builder(this);
@ -128,6 +136,11 @@ public class TuskyApplication extends Application implements HasActivityInjector
return dispatchingAndroidInjector;
}
@Override
public AndroidInjector<Service> serviceInjector() {
return dispatchingServiceInjector;
}
public interface ServiceLocator {
<T> T get(Class<T> clazz);
}

View file

@ -0,0 +1,55 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Emoji
import com.squareup.picasso.Picasso
class EmojiAdapter(private val emojiList: List<Emoji>, private val onEmojiSelectedListener: OnEmojiSelectedListener) : RecyclerView.Adapter<EmojiAdapter.EmojiHolder>() {
override fun getItemCount(): Int {
return emojiList.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiAdapter.EmojiHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji_button, parent, false) as ImageView
return EmojiHolder(view)
}
override fun onBindViewHolder(viewHolder: EmojiAdapter.EmojiHolder, position: Int) {
Picasso.with(viewHolder.emojiImageView.context)
.load(emojiList[position].url)
.into(viewHolder.emojiImageView)
viewHolder.emojiImageView.setOnClickListener {
onEmojiSelectedListener.onEmojiSelected(emojiList[position].shortcode)
}
}
class EmojiHolder(val emojiImageView: ImageView) : RecyclerView.ViewHolder(emojiImageView)
}
interface OnEmojiSelectedListener {
fun onEmojiSelected(shortcode: String)
}

View file

@ -37,8 +37,8 @@ import android.widget.TextView;
import android.widget.ToggleButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -485,7 +485,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
Spanned content = statusViewData.getContent();
List<Status.Emoji> emojis = statusViewData.getEmojis();
List<Emoji> emojis = statusViewData.getEmojis();
Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent);

View file

@ -19,6 +19,7 @@ import android.widget.ToggleButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -106,7 +107,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
username.setText(usernameText);
}
private void setContent(Spanned content, Status.Mention[] mentions, List<Status.Emoji> emojis,
private void setContent(Spanned content, Status.Mention[] mentions, List<Emoji> emojis,
StatusActionListener listener) {
Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content);
@ -384,7 +385,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
sensitiveMediaShow.setVisibility(View.GONE);
}
private void setSpoilerText(String spoilerText, List<Status.Emoji> emojis,
private void setSpoilerText(String spoilerText, List<Emoji> emojis,
final boolean expanded, final StatusActionListener listener) {
CharSequence emojiSpoiler =
CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription);

View file

@ -15,9 +15,7 @@
package com.keylesspalace.tusky.db
import android.arch.persistence.room.Database
import android.util.Log
import com.keylesspalace.tusky.TuskyApplication
import com.keylesspalace.tusky.entity.Account
/**

View file

@ -25,11 +25,12 @@ import android.support.annotation.NonNull;
* DB version & declare DAO
*/
@Database(entities = {TootEntity.class, AccountEntity.class}, version = 5, exportSchema = false)
@Database(entities = {TootEntity.class, AccountEntity.class, EmojiListEntity.class}, version = 6, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
public abstract AccountDao accountDao();
public abstract EmojiListDao emojiListDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -74,4 +75,11 @@ public abstract class AppDatabase extends RoomDatabase {
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`))");
}
};
}

View file

@ -0,0 +1,31 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db
import android.arch.persistence.room.Dao
import android.arch.persistence.room.Insert
import android.arch.persistence.room.OnConflictStrategy
import android.arch.persistence.room.Query
@Dao
interface EmojiListDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(emojiList: EmojiListEntity)
@Query("SELECT * FROM EmojiListEntity WHERE instance = :instance LIMIT 1")
fun loadEmojisForInstance(instance: String): EmojiListEntity?
}

View file

@ -0,0 +1,43 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db
import android.arch.persistence.room.Entity
import android.arch.persistence.room.PrimaryKey
import android.arch.persistence.room.TypeConverter
import android.arch.persistence.room.TypeConverters
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.entity.Emoji
@Entity
@TypeConverters(Converters::class)
data class EmojiListEntity(@field:PrimaryKey var instance: String,
val emojiList: List<Emoji>)
class Converters {
@TypeConverter
fun jsonToList(emojiListJson: String): List<Emoji> {
return Gson().fromJson(emojiListJson, object : TypeToken<List<Emoji>>() {}.type)
}
@TypeConverter
fun listToJson(emojiList: List<Emoji>): String {
return Gson().toJson(emojiList)
}
}

View file

@ -38,4 +38,7 @@ public interface TootDao {
@Query("DELETE FROM TootEntity WHERE uid = :uid")
int delete(int uid);
@Query("SELECT * FROM TootEntity WHERE uid = :uid")
TootEntity find(int uid);
}

View file

@ -31,7 +31,8 @@ import javax.inject.Singleton
AppModule::class,
NetworkModule::class,
AndroidInjectionModule::class,
ActivitiesModule::class
ActivitiesModule::class,
ServicesModule::class
])
interface AppComponent {
@Component.Builder

View file

@ -21,7 +21,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.preference.PreferenceManager
import android.support.v4.content.LocalBroadcastManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TuskyApplication
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.network.MastodonApi

View file

@ -0,0 +1,26 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.service.SendTootService
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class ServicesModule {
@ContributesAndroidInjector
abstract fun contributeMyService(): SendTootService
}

View file

@ -0,0 +1,25 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class Emoji(
val shortcode: String,
val url: String
) : Parcelable

View file

@ -131,11 +131,6 @@ data class Status(
var website: String? = null
}
class Emoji {
val shortcode: String? = null
val url: String? = null
}
companion object {
const val MAX_MEDIA_ATTACHMENTS = 4
}

View file

@ -31,7 +31,6 @@ import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.AccountActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.AccountAdapter;
import com.keylesspalace.tusky.adapter.BlocksAdapter;

View file

@ -1,161 +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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.DrawableRes;
import android.support.annotation.Nullable;
import android.support.design.widget.BottomSheetDialogFragment;
import android.support.graphics.drawable.VectorDrawableCompat;
import android.support.v4.graphics.drawable.DrawableCompat;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.util.ThemeUtils;
public class ComposeOptionsFragment extends BottomSheetDialogFragment {
public interface Listener {
void onVisibilityChanged(Status.Visibility visibility);
void onContentWarningChanged(boolean hideText);
}
private RadioGroup radio;
private CheckBox hideText;
private Listener listener;
public static ComposeOptionsFragment newInstance(Status.Visibility visibility, boolean hideText) {
Bundle arguments = new Bundle();
ComposeOptionsFragment fragment = new ComposeOptionsFragment();
arguments.putInt("visibilityNum", visibility.getNum());
arguments.putBoolean("hideText", hideText);
fragment.setArguments(arguments);
return fragment;
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
listener = (Listener) context;
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_compose_options, container, false);
Bundle arguments = getArguments();
Status.Visibility visibility = Status.Visibility.byNum(
arguments.getInt("visibilityNum", 0)
);
boolean statusHideText = arguments.getBoolean("hideText");
radio = rootView.findViewById(R.id.radio_visibility);
int radioCheckedId = R.id.radio_public;
switch (visibility) {
case PUBLIC: radioCheckedId = R.id.radio_public; break;
case PRIVATE: radioCheckedId = R.id.radio_private; break;
case UNLISTED: radioCheckedId = R.id.radio_unlisted; break;
case DIRECT: radioCheckedId = R.id.radio_direct; break;
}
radio.check(radioCheckedId);
RadioButton publicButton = rootView.findViewById(R.id.radio_public);
RadioButton unlistedButton = rootView.findViewById(R.id.radio_unlisted);
RadioButton privateButton = rootView.findViewById(R.id.radio_private);
RadioButton directButton = rootView.findViewById(R.id.radio_direct);
setRadioButtonDrawable(getContext(), publicButton, R.drawable.ic_public_24dp);
setRadioButtonDrawable(getContext(), unlistedButton, R.drawable.ic_lock_open_24dp);
setRadioButtonDrawable(getContext(), privateButton, R.drawable.ic_lock_outline_24dp);
setRadioButtonDrawable(getContext(), directButton, R.drawable.ic_email_24dp);
hideText = rootView.findViewById(R.id.compose_hide_text);
hideText.setChecked(statusHideText);
return rootView;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
radio.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(RadioGroup group, int checkedId) {
Status.Visibility visibility;
switch (checkedId) {
default:
case R.id.radio_public: {
visibility = Status.Visibility.PUBLIC;
break;
}
case R.id.radio_unlisted: {
visibility = Status.Visibility.UNLISTED;
break;
}
case R.id.radio_private: {
visibility = Status.Visibility.PRIVATE;
break;
}
case R.id.radio_direct: {
visibility = Status.Visibility.DIRECT;
break;
}
}
listener.onVisibilityChanged(visibility);
}
});
hideText.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onContentWarningChanged(isChecked);
}
});
}
private static void setRadioButtonDrawable(Context context, RadioButton button,
@DrawableRes int id) {
ColorStateList list = new ColorStateList(new int[][] {
new int[] { -android.R.attr.state_checked },
new int[] { android.R.attr.state_checked }
}, new int[] {
ThemeUtils.getColor(context, R.attr.compose_image_button_tint),
ThemeUtils.getColor(context, R.attr.colorAccent)
});
Drawable drawable = VectorDrawableCompat.create(context.getResources(), id,
context.getTheme());
if (drawable == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
button.setButtonTintList(list);
} else {
drawable = DrawableCompat.wrap(drawable);
DrawableCompat.setTintList(drawable, list);
}
button.setButtonDrawable(drawable);
}
}

View file

@ -20,18 +20,14 @@ import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.view.ViewCompat;
import android.support.v7.widget.PopupMenu;
import android.text.Spanned;
import android.view.MenuItem;
import android.view.View;
import com.keylesspalace.tusky.AccountActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.ComposeActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ReportActivity;
@ -41,27 +37,16 @@ import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.ViewVideoActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AdapterItemRemover;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.HtmlUtils;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding

View file

@ -22,6 +22,7 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.AppCredentials;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.MastoList;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Relationship;
@ -101,12 +102,15 @@ public interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/statuses")
Call<Status> createStatus(
@Header("Authorization") String auth,
@Header(DOMAIN_HEADER) String domain,
@Field("status") String text,
@Field("in_reply_to_id") String inReplyToId,
@Field("spoiler_text") String warningText,
@Field("visibility") String visibility,
@Field("sensitive") Boolean sensitive,
@Field("media_ids[]") List<String> mediaIds);
@Field("media_ids[]") List<String> mediaIds,
@Header("Idempotency-Key") String idempotencyKey);
@GET("api/v1/statuses/{id}")
Call<Status> status(@Path("id") String statusId);
@GET("api/v1/statuses/{id}/context")
@ -263,4 +267,7 @@ public interface MastodonApi {
@GET("/api/v1/lists")
Call<List<MastoList>> getLists();
@GET("/api/v1/custom_emojis")
Call<List<Emoji>> getCustomEmojis();
}

View file

@ -0,0 +1,329 @@
package com.keylesspalace.tusky.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.support.v4.app.NotificationCompat
import android.support.v4.app.ServiceCompat
import android.support.v4.content.ContextCompat
import android.support.v4.content.LocalBroadcastManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TuskyApplication
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.receiver.TimelineReceiver
import com.keylesspalace.tusky.util.SaveTootHelper
import com.keylesspalace.tusky.util.StringUtils
import dagger.android.AndroidInjection
import kotlinx.android.parcel.Parcelize
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class SendTootService: Service(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
@Inject
lateinit var accountManager: AccountManager
private lateinit var saveTootHelper: SaveTootHelper
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
private val timer = Timer()
override fun onCreate() {
AndroidInjection.inject(this)
saveTootHelper = SaveTootHelper(TuskyApplication.getDB().tootDao(), this)
super.onCreate()
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if(intent.hasExtra(KEY_TOOT)) {
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
if (tootToSend == null) {
throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
var notificationText = tootToSend.warningText
if (notificationText.isBlank()) {
notificationText = tootToSend.text
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentText(notificationText)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.primary))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(notificationId))
if(tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(notificationId, builder.build())
} else {
notificationManager.notify(notificationId, builder.build())
}
tootsToSend[notificationId] = tootToSend
sendToot(notificationId)
notificationId--
} else {
if(intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
stopSelf(intent.getIntExtra(KEY_CANCEL, 0))
}
}
return START_NOT_STICKY
}
private fun sendToot(tootId: Int) {
// when tootToSend == null, sending has been canceled
val tootToSend = tootsToSend[tootId] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId)
if(account == null) {
tootsToSend.remove(tootId)
return
}
tootToSend.retries++
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
tootToSend.text,
tootToSend.inReplyToId,
tootToSend.warningText,
tootToSend.visibility,
tootToSend.sensitive,
tootToSend.mediaIds,
tootToSend.idempotencyKey
)
sendCalls[tootId] = sendCall
val callback = object: Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
tootsToSend.remove(tootId)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (response.isSuccessful) {
val intent = Intent(TimelineReceiver.Types.STATUS_COMPOSED)
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
// If the status was loaded from a draft, delete the draft and associated media files.
if(tootToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(tootToSend.savedTootUid)
}
if (tootsToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
notificationManager.cancel(tootId)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(tootToSend)
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.primary))
notificationManager.notify(tootId, builder.build())
if (tootsToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_DETACH)
stopSelf()
}
}
}
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = 1000L*tootToSend.retries
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
}
}, backoff)
}
}
sendCall.enqueue(callback)
}
private fun cancelSending(tootId: Int) {
val tootToCancel = tootsToSend.remove(tootId)
if(tootToCancel != null) {
val sendCall = sendCalls.remove(tootId)
sendCall?.cancel()
saveTootToDrafts(tootToCancel)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_cancel_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.primary))
notificationManager.notify(tootId, builder.build())
timer.schedule(object : TimerTask() {
override fun run() {
notificationManager.cancel(tootId)
}
}, 5000)
if (tootsToSend.isEmpty()) {
ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_DETACH)
stopSelf()
}
}
}
private fun saveTootToDrafts(toot: TootToSend) {
saveTootHelper.saveToot(toot.text,
toot.warningText,
toot.savedJsonUrls,
toot.mediaUris,
toot.savedTootUid,
toot.inReplyToId,
toot.replyingStatusContent,
toot.replyingStatusAuthorUsername,
Status.Visibility.byString(toot.visibility))
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
val intent = Intent(this, SendTootService::class.java)
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
companion object {
private const val KEY_TOOT = "toot"
private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_toots"
private const val MAX_RETRY_INTERVAL = 60*1000L // 1 minute
private var notificationId = -1 // use negative ids to not clash with other notis
@JvmStatic
fun sendTootIntent(context: Context,
text: String,
warningText: String,
visibility: Status.Visibility,
sensitive: Boolean,
mediaIds: List<String>,
mediaUris: List<String>,
inReplyToId: String?,
replyingStatusContent: String?,
replyingStatusAuthorUsername: String?,
savedJsonUrls: String?,
account: AccountEntity,
savedTootUid: Int
): Intent {
val intent = Intent(context, SendTootService::class.java)
val idempotencyKey = StringUtils.randomAlphanumericString(16)
val tootToSend = TootToSend(text,
warningText,
visibility.serverString(),
sensitive,
mediaIds,
mediaUris,
inReplyToId,
replyingStatusContent,
replyingStatusAuthorUsername,
savedJsonUrls,
account.id,
savedTootUid,
idempotencyKey,
0)
intent.putExtra(KEY_TOOT, tootToSend)
return intent
}
}
}
@Parcelize
data class TootToSend(val text: String,
val warningText: String,
val visibility: String,
val sensitive: Boolean,
val mediaIds: List<String>,
val mediaUris: List<String>,
val inReplyToId: String?,
val replyingStatusContent: String?,
val replyingStatusAuthorUsername: String?,
val savedJsonUrls: String?,
val accountId: Long,
val savedTootUid: Int,
val idempotencyKey: String,
var retries: Int): Parcelable

View file

@ -28,7 +28,7 @@ import android.text.SpannedString;
import android.text.style.ReplacementSpan;
import android.widget.TextView;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.Emoji;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.Target;
@ -46,12 +46,12 @@ public class CustomEmojiHelper {
* @param textView a reference to the textView the emojis will be shown in
* @return the text with the shortcodes replaced by EmojiSpans
*/
public static Spanned emojifyText(Spanned text, List<Status.Emoji> emojis, final TextView textView) {
public static Spanned emojifyText(Spanned text, List<Emoji> emojis, final TextView textView) {
if (!emojis.isEmpty()) {
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (Status.Emoji emoji : emojis) {
for (Emoji emoji : emojis) {
CharSequence pattern = new StringBuilder(":").append(emoji.getShortcode()).append(':');
Matcher matcher = Pattern.compile(pattern.toString()).matcher(text);
while (matcher.find()) {
@ -71,7 +71,7 @@ public class CustomEmojiHelper {
return text;
}
public static Spanned emojifyString(String string, List<Status.Emoji> emojis, final TextView textView) {
public static Spanned emojifyString(String string, List<Emoji> emojis, final TextView textView) {
return emojifyText(new SpannedString(string), emojis, textView);
}

View file

@ -15,14 +15,22 @@
package com.keylesspalace.tusky.util;
import android.content.ContentResolver;
import android.net.Uri;
import android.support.annotation.Nullable;
import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class IOUtils {
public static void closeQuietly(@Nullable InputStream stream) {
private static final int DEFAULT_BLOCKSIZE = 16384;
public static void closeQuietly(@Nullable Closeable stream) {
try {
if (stream != null) {
stream.close();
@ -32,13 +40,32 @@ public class IOUtils {
}
}
public static void closeQuietly(@Nullable OutputStream stream) {
public static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) {
InputStream from;
FileOutputStream to;
try {
if (stream != null) {
stream.close();
from = contentResolver.openInputStream(uri);
to = new FileOutputStream(file);
} catch (FileNotFoundException e) {
return false;
}
if (from == null) {
return false;
}
byte[] chunk = new byte[DEFAULT_BLOCKSIZE];
try {
while (true) {
int bytes = from.read(chunk, 0, chunk.length);
if (bytes < 0) {
break;
}
to.write(chunk, 0, bytes);
}
} catch (IOException e) {
// intentionally unhandled
return false;
}
closeQuietly(from);
closeQuietly(to);
return true;
}
}

View file

@ -0,0 +1,197 @@
package com.keylesspalace.tusky.util;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.AsyncTask;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.FileProvider;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.entity.Status;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public final class SaveTootHelper {
private static final String TAG = "SaveTootHelper";
private TootDao tootDao;
private Context context;
public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) {
this.tootDao = tootDao;
this.context = context;
}
@SuppressLint("StaticFieldLeak")
public boolean saveToot(@NonNull String content,
@NonNull String contentWarning,
@Nullable String savedJsonUrls,
@NonNull List<String> mediaUris,
int savedTootUid,
@Nullable String inReplyToId,
@Nullable String replyingStatusContent,
@Nullable String replyingStatusAuthorUsername,
@NonNull Status.Visibility statusVisibility) {
if (TextUtils.isEmpty(content) && mediaUris.isEmpty()) {
return false;
}
// Get any existing file's URIs.
ArrayList<String> existingUris = null;
if (!TextUtils.isEmpty(savedJsonUrls)) {
existingUris = new Gson().fromJson(savedJsonUrls,
new TypeToken<ArrayList<String>>() {
}.getType());
}
String mediaUrlsSerialized = null;
if (!ListUtils.isEmpty(mediaUris)) {
List<String> savedList = saveMedia(mediaUris, existingUris);
if (!ListUtils.isEmpty(savedList)) {
mediaUrlsSerialized = new Gson().toJson(savedList);
if (!ListUtils.isEmpty(existingUris)) {
deleteMedia(setDifference(existingUris, savedList));
}
} else {
return false;
}
} else if (!ListUtils.isEmpty(existingUris)) {
/* If there were URIs in the previous draft, but they've now been removed, those files
* can be deleted. */
deleteMedia(existingUris);
}
final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, contentWarning,
inReplyToId,
replyingStatusContent,
replyingStatusAuthorUsername,
statusVisibility);
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
tootDao.insertOrReplace(toot);
return null;
}
}.execute();
return true;
}
public void deleteDraft(int tootId) {
TootEntity item = tootDao.find(tootId);
if(item != null) {
deleteDraft(item);
}
}
public void deleteDraft(@NonNull TootEntity item){
// Delete any media files associated with the status.
ArrayList<String> uris = new Gson().fromJson(item.getUrls(),
new TypeToken<ArrayList<String>>() {}.getType());
if (uris != null) {
for (String uriString : uris) {
Uri uri = Uri.parse(uriString);
if (context.getContentResolver().delete(uri, null, null) == 0) {
Log.e(TAG, String.format("Did not delete file %s.", uriString));
}
}
}
// update DB
tootDao.delete(item.getUid());
}
@Nullable
private List<String> saveMedia(@NonNull List<String> mediaUris,
@Nullable List<String> existingUris) {
File directory = context.getExternalFilesDir("Tusky");
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save media.");
return null;
}
ContentResolver contentResolver = context.getContentResolver();
ArrayList<File> filesSoFar = new ArrayList<>();
ArrayList<String> results = new ArrayList<>();
for (String mediaUri : mediaUris) {
/* If the media was already saved in a previous draft, there's no need to save another
* copy, just add the existing URI to the results. */
if (existingUris != null) {
int index = existingUris.indexOf(mediaUri);
if (index != -1) {
results.add(mediaUri);
continue;
}
}
// Otherwise, save the media.
Uri uri = Uri.parse(mediaUri);
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
String mimeType = contentResolver.getType(uri);
MimeTypeMap map = MimeTypeMap.getSingleton();
String fileExtension = map.getExtensionFromMimeType(mimeType);
String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension);
File file = new File(directory, filename);
filesSoFar.add(file);
boolean copied = IOUtils.copyToFile(contentResolver, uri, file);
if (!copied) {
/* If any media files were created in prior iterations, delete those before
* returning. */
for (File earlierFile : filesSoFar) {
boolean deleted = earlierFile.delete();
if (!deleted) {
Log.i(TAG, "Could not delete the file " + earlierFile.toString());
}
}
return null;
}
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+".fileprovider", file);
results.add(resultUri.toString());
}
return results;
}
private void deleteMedia(List<String> mediaUris) {
for (String uriString : mediaUris) {
Uri uri = Uri.parse(uriString);
if (context.getContentResolver().delete(uri, null, null) == 0) {
Log.e(TAG, String.format("Did not delete file %s.", uriString));
}
}
}
/**
* AB={xA|xB}
*
* @return all elements of set A that are not in set B.
*/
private static List<String> setDifference(List<String> a, List<String> b) {
List<String> c = new ArrayList<>();
for (String s : a) {
if (!b.contains(s)) {
c.add(s);
}
}
return c;
}
}

View file

@ -0,0 +1,80 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.widget.LinearLayout
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Status
import kotlinx.android.synthetic.main.view_compose_options.view.*
class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : LinearLayout(context, attrs, defStyleAttr) {
var listener: ComposeOptionsListener? = null
init {
inflate(context, R.layout.view_compose_options, this)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
publicRadioButton.setButtonDrawable(R.drawable.ic_public_24dp)
unlistedRadioButton.setButtonDrawable(R.drawable.ic_lock_open_24dp)
privateRadioButton.setButtonDrawable(R.drawable.ic_lock_outline_24dp)
directRadioButton.setButtonDrawable(R.drawable.ic_email_24dp)
}
visibilityRadioGroup.setOnCheckedChangeListener({ _, checkedId ->
val visibility = when (checkedId) {
R.id.publicRadioButton ->
Status.Visibility.PUBLIC
R.id.unlistedRadioButton ->
Status.Visibility.UNLISTED
R.id.privateRadioButton ->
Status.Visibility.PRIVATE
R.id.directRadioButton ->
Status.Visibility.DIRECT
else ->
Status.Visibility.PUBLIC
}
listener?.onVisibilityChanged(visibility)
})
}
fun setStatusVisibility(visibility: Status.Visibility) {
val selectedButton = when (visibility) {
Status.Visibility.PUBLIC ->
R.id.publicRadioButton
Status.Visibility.UNLISTED ->
R.id.unlistedRadioButton
Status.Visibility.PRIVATE ->
R.id.privateRadioButton
Status.Visibility.DIRECT ->
R.id.directRadioButton
else ->
R.id.directRadioButton
}
visibilityRadioGroup.check(selectedButton)
}
}
interface ComposeOptionsListener {
fun onVisibilityChanged(visibility: Status.Visibility)
}

View file

@ -1,59 +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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view;
import android.content.Context;
import android.support.v13.view.inputmethod.EditorInfoCompat;
import android.support.v13.view.inputmethod.InputConnectionCompat;
import android.support.v7.widget.AppCompatMultiAutoCompleteTextView;
import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import com.keylesspalace.tusky.util.Assert;
public class EditTextTyped extends AppCompatMultiAutoCompleteTextView {
private InputConnectionCompat.OnCommitContentListener onCommitContentListener;
private String[] mimeTypes;
public EditTextTyped(Context context) {
super(context);
}
public EditTextTyped(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
}
public void setMimeTypes(String[] types,
InputConnectionCompat.OnCommitContentListener listener) {
mimeTypes = types;
onCommitContentListener = listener;
}
@Override
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
InputConnection connection = super.onCreateInputConnection(editorInfo);
if (onCommitContentListener != null) {
Assert.expect(mimeTypes != null);
EditorInfoCompat.setContentMimeTypes(editorInfo, mimeTypes);
return InputConnectionCompat.createWrapper(connection, editorInfo,
onCommitContentListener);
} else {
return connection;
}
}
}

View file

@ -0,0 +1,53 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view
import android.content.Context
import android.support.v13.view.inputmethod.EditorInfoCompat
import android.support.v13.view.inputmethod.InputConnectionCompat
import android.support.v7.widget.AppCompatMultiAutoCompleteTextView
import android.text.InputType
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
class EditTextTyped @JvmOverloads constructor(context: Context,
attributeSet: AttributeSet? = null)
: AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
init {
//fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType
}
fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) {
onCommitContentListener = listener
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) {
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
InputConnectionCompat.createWrapper(connection, editorInfo,
onCommitContentListener!!)
} else {
connection
}
}
}

View file

@ -0,0 +1,76 @@
/* Copyright 2018 Conny Duck
*
* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.view
import android.content.Context
import android.graphics.Color
import android.support.v7.widget.AppCompatButton
import android.util.AttributeSet
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Status
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.mikepenz.iconics.IconicsDrawable
class TootButton
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatButton(context, attrs, defStyleAttr) {
private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button)
init {
if(smallStyle) {
setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_send_24dp, 0, 0, 0)
} else {
compoundDrawablePadding = context.resources.getDimensionPixelSize(R.dimen.toot_button_drawable_padding)
setText(R.string.action_send)
}
}
fun setStatusVisibility(visibility: Status.Visibility) {
if(!smallStyle) {
when (visibility) {
Status.Visibility.PUBLIC -> {
setText(R.string.action_send_public)
setCompoundDrawables(null, null, null, null)
}
Status.Visibility.UNLISTED -> {
setText(R.string.action_send)
setCompoundDrawables(null, null, null, null)
}
Status.Visibility.PRIVATE,
Status.Visibility.DIRECT -> {
addLock()
}
else -> {
setCompoundDrawables(null, null, null, null)
}
}
}
}
private fun addLock() {
setText(R.string.action_send)
val lock = IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).sizeDp(18).color(Color.WHITE)
setCompoundDrawablesWithIntrinsicBounds(lock, null, null, null)
}
}

View file

@ -20,6 +20,7 @@ import android.text.Spanned;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Status;
import java.util.Collections;
@ -68,7 +69,7 @@ public abstract class StatusViewData {
private final String senderId;
private final boolean rebloggingEnabled;
private final Status.Application application;
private final List<Status.Emoji> emojis;
private final List<Emoji> emojis;
@Nullable
private final Card card;
@ -78,7 +79,7 @@ public abstract class StatusViewData {
boolean isShowingContent, String userFullName, String nickname, String avatar,
Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId,
@Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled,
Status.Application application, List<Status.Emoji> emojis, @Nullable Card card) {
Status.Application application, List<Emoji> emojis, @Nullable Card card) {
this.id = id;
this.content = content;
this.reblogged = reblogged;
@ -203,7 +204,7 @@ public abstract class StatusViewData {
return application;
}
public List<Status.Emoji> getEmojis() {
public List<Emoji> getEmojis() {
return emojis;
}
@ -250,7 +251,7 @@ public abstract class StatusViewData {
private String senderId;
private boolean rebloggingEnabled;
private Status.Application application;
private List<Status.Emoji> emojis;
private List<Emoji> emojis;
private Card card;
public Builder() {
@ -399,7 +400,7 @@ public abstract class StatusViewData {
return this;
}
public Builder setEmojis(List<Status.Emoji> emojis) {
public Builder setEmojis(List<Emoji> emojis) {
this.emojis = emojis;
return this;
}