ストリーミングAPI対応。画面復帰時に自動リフレッシュ。

This commit is contained in:
tateisu 2017-05-10 15:03:17 +09:00
parent 5343c6e44c
commit c32415842c
19 changed files with 983 additions and 224 deletions

View File

@ -9,8 +9,8 @@ android {
applicationId "jp.juggler.subwaytooter"
minSdkVersion 21
targetSdkVersion 25
versionCode 43
versionName "0.4.3"
versionCode 44
versionName "0.4.4"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}

View File

@ -50,6 +50,9 @@ public class ActAppSetting extends AppCompatActivity
Switch swExitAppWhenCloseProtectedColumn;
Switch swShowFollowButtonInButtonBar;
Switch swDontRound;
Switch swDontUseStreaming;
Switch swDontRefreshOnResume;
Spinner spBackButtonAction;
Spinner spUITheme;
@ -99,6 +102,12 @@ public class ActAppSetting extends AppCompatActivity
swDontRound = (Switch) findViewById( R.id.swDontRound );
swDontRound.setOnCheckedChangeListener( this );
swDontUseStreaming = (Switch) findViewById( R.id.swDontUseStreaming );
swDontUseStreaming.setOnCheckedChangeListener( this );
swDontRefreshOnResume = (Switch) findViewById( R.id.swDontRefreshOnResume );
swDontRefreshOnResume.setOnCheckedChangeListener( this );
cbNotificationSound = (CheckBox) findViewById( R.id.cbNotificationSound );
cbNotificationVibration = (CheckBox) findViewById( R.id.cbNotificationVibration );
cbNotificationLED = (CheckBox) findViewById( R.id.cbNotificationLED );
@ -190,6 +199,8 @@ public class ActAppSetting extends AppCompatActivity
swExitAppWhenCloseProtectedColumn.setChecked( pref.getBoolean( Pref.KEY_EXIT_APP_WHEN_CLOSE_PROTECTED_COLUMN, false ) );
swShowFollowButtonInButtonBar.setChecked( pref.getBoolean( Pref.KEY_SHOW_FOLLOW_BUTTON_IN_BUTTON_BAR, false ) );
swDontRound.setChecked( pref.getBoolean( Pref.KEY_DONT_ROUND, false ) );
swDontUseStreaming.setChecked( pref.getBoolean( Pref.KEY_DONT_USE_STREAMING, false ) );
swDontRefreshOnResume.setChecked( pref.getBoolean( Pref.KEY_DONT_REFRESH_ON_RESUME , false ) );
cbNotificationSound.setChecked( pref.getBoolean( Pref.KEY_NOTIFICATION_SOUND, true ) );
cbNotificationVibration.setChecked( pref.getBoolean( Pref.KEY_NOTIFICATION_VIBRATION, true ) );
@ -220,6 +231,8 @@ public class ActAppSetting extends AppCompatActivity
.putBoolean( Pref.KEY_EXIT_APP_WHEN_CLOSE_PROTECTED_COLUMN, swExitAppWhenCloseProtectedColumn.isChecked() )
.putBoolean( Pref.KEY_SHOW_FOLLOW_BUTTON_IN_BUTTON_BAR, swShowFollowButtonInButtonBar.isChecked() )
.putBoolean( Pref.KEY_DONT_ROUND, swDontRound.isChecked() )
.putBoolean( Pref.KEY_DONT_USE_STREAMING, swDontUseStreaming.isChecked() )
.putBoolean( Pref.KEY_DONT_REFRESH_ON_RESUME, swDontRefreshOnResume.isChecked() )
.putBoolean( Pref.KEY_NOTIFICATION_SOUND, cbNotificationSound.isChecked() )
.putBoolean( Pref.KEY_NOTIFICATION_VIBRATION, cbNotificationVibration.isChecked() )

View File

@ -31,8 +31,6 @@ import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import com.yasesprox.android.transcommusdk.TransCommuActivity;
import org.json.JSONException;
import org.json.JSONObject;
@ -65,7 +63,7 @@ import okhttp3.Request;
import okhttp3.RequestBody;
public class ActMain extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener, View.OnClickListener, ViewPager.OnPageChangeListener
implements NavigationView.OnNavigationItemSelectedListener, View.OnClickListener, ViewPager.OnPageChangeListener, Column.Callback
{
public static final LogCategory log = new LogCategory( "ActMain" );
@ -115,7 +113,14 @@ public class ActMain extends AppCompatActivity
super.onDestroy();
}
boolean bResume;
@Override public boolean isActivityResume(){
return bResume;
}
@Override protected void onResume(){
bResume = true;
log.d( "onResume" );
super.onResume();
HTMLDecoder.link_callback = link_click_listener;
@ -156,10 +161,6 @@ public class ActMain extends AppCompatActivity
posted_acct = null;
}
if( pager_adapter.getCount() == 0 ){
llEmpty.setVisibility( View.VISIBLE );
}
Uri uri = ActCallback.last_uri.getAndSet( null );
if( uri != null ){
handleIntentUri( uri );
@ -169,6 +170,15 @@ public class ActMain extends AppCompatActivity
if( intent != null ){
handleSentIntent( intent );
}
if( pager_adapter.getCount() == 0 ){
llEmpty.setVisibility( View.VISIBLE );
}else{
for(Column column : app_state.column_list ){
column.onResume(this);
}
}
}
private void handleSentIntent( final Intent sent_intent ){
@ -192,8 +202,12 @@ public class ActMain extends AppCompatActivity
}
@Override protected void onPause(){
bResume = false;
closeListItemPopup();
app_state.stream_reader.onPause();
HTMLDecoder.link_callback = null;
super.onPause();
}
@ -268,7 +282,7 @@ public class ActMain extends AppCompatActivity
llEmpty.setVisibility( View.VISIBLE );
}else{
int select = data.getIntExtra( ActColumnList.EXTRA_SELECTION, - 1 );
if( select != - 1 ){
if( 0 <= select && select < pager_adapter.getCount() ){
pager.setCurrentItem( select, true );
scrollColumnStrip( select );
}
@ -347,7 +361,7 @@ public class ActMain extends AppCompatActivity
ActionsDialog dialog = new ActionsDialog();
dialog.addAction( getString( R.string.close_column ), new Runnable() {
@Override public void run(){
performColumnClose( true, pager_adapter.getColumn( pager.getCurrentItem() ) );
closeColumn( true, pager_adapter.getColumn( pager.getCurrentItem() ) );
}
} );
dialog.addAction( getString( R.string.open_column_list ), new Runnable() {
@ -373,7 +387,7 @@ public class ActMain extends AppCompatActivity
ActMain.this.finish();
return;
}
performColumnClose( false, column );
closeColumn( false, column );
}
break;
@ -1042,7 +1056,7 @@ public class ActMain extends AppCompatActivity
}
}
public void performColumnClose( boolean bConfirm, final Column column ){
public void closeColumn( boolean bConfirm, final Column column ){
if( column.dont_close ){
Utils.showToast( this, false, R.string.column_has_dont_close_option );
return;
@ -1055,7 +1069,7 @@ public class ActMain extends AppCompatActivity
.setPositiveButton( R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick( DialogInterface dialog, int which ){
performColumnClose( true, column );
closeColumn( true, column );
}
} )
.show();
@ -1088,7 +1102,7 @@ public class ActMain extends AppCompatActivity
//
llEmpty.setVisibility( View.GONE );
//
Column col = new Column( app_state, ai, type, params );
Column col = new Column( app_state, ai, this,type, params );
int idx = pager_adapter.addColumn( pager, col );
app_state.saveColumnList();
updateColumnStrip();
@ -1140,7 +1154,7 @@ public class ActMain extends AppCompatActivity
}
Utils.showToast( ActMain.this, false, R.string.app_was_muted );
}
//////////////////////////////////////////////////////////////
interface GetAccountCallback {

View File

@ -2,6 +2,7 @@ package jp.juggler.subwaytooter;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import org.apache.commons.io.IOUtils;
import org.json.JSONArray;
@ -26,12 +27,16 @@ class AppState {
final Context context;
final float density;
final SharedPreferences pref;
final Handler handler;
final StreamReader stream_reader;
AppState( Context applicationContext ,SharedPreferences pref){
this.context = applicationContext;
this.pref = pref;
this.density = context.getResources().getDisplayMetrics().density;
this.stream_reader = new StreamReader(applicationContext,pref);
this.handler = new Handler();
if( ! isLoaded ){
isLoaded = true;

View File

@ -9,11 +9,15 @@ import android.text.TextUtils;
import android.view.View;
import android.widget.ListView;
import com.omadahealth.github.swipyrefreshlayout.library.SwipyRefreshLayoutDirection;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
@ -37,6 +41,7 @@ import jp.juggler.subwaytooter.table.MutedWord;
import jp.juggler.subwaytooter.table.SavedAccount;
import jp.juggler.subwaytooter.table.UserRelation;
import jp.juggler.subwaytooter.util.BucketList;
import jp.juggler.subwaytooter.util.DuplicateMap;
import jp.juggler.subwaytooter.util.LogCategory;
import jp.juggler.subwaytooter.util.MyListView;
import jp.juggler.subwaytooter.util.ScrollPosition;
@ -45,6 +50,26 @@ import jp.juggler.subwaytooter.util.Utils;
class Column {
private static final LogCategory log = new LogCategory( "Column" );
interface Callback {
boolean isActivityResume();
}
private WeakReference< Callback > callback_ref;
private boolean isResume(){
if( callback_ref == null ){
log.d("isResume: callback_ref is not set");
return false;
}
Callback cb = callback_ref.get();
if( cb == null ){
log.d("isResume: callback was lost.");
return false;
}
return cb.isActivityResume();
}
private static Object getParamAt( Object[] params, int idx ){
if( params == null || idx >= params.length ){
throw new IndexOutOfBoundsException( "getParamAt idx=" + idx );
@ -164,11 +189,12 @@ class Column {
ScrollPosition scroll_save;
Column( @NonNull AppState app_state, @NonNull SavedAccount access_info, int type, Object... params ){
Column( @NonNull AppState app_state, @NonNull SavedAccount access_info, @NonNull Callback callback,int type, Object... params ){
this.app_state = app_state;
this.context = app_state.context;
this.access_info = access_info;
this.column_type = type;
this.callback_ref = new WeakReference< Callback >( callback );
switch( type ){
case TYPE_CONVERSATION:
@ -203,13 +229,12 @@ class Column {
item.put( KEY_DONT_SHOW_REPLY, dont_show_reply );
item.put( KEY_REGEX_TEXT, regex_text );
item.put( KEY_HEADER_BACKGROUND_COLOR, header_bg_color );
item.put( KEY_HEADER_TEXT_COLOR, header_fg_color );
item.put( KEY_COLUMN_BACKGROUND_COLOR, column_bg_color );
item.put( KEY_COLUMN_BACKGROUND_IMAGE, column_bg_image );
item.put( KEY_COLUMN_BACKGROUND_IMAGE_ALPHA, (double) column_bg_image_alpha );
switch( column_type ){
case TYPE_CONVERSATION:
case TYPE_BOOSTED_BY:
@ -238,7 +263,7 @@ class Column {
item.put( KEY_OLD_INDEX, old_index );
}
Column( @NonNull AppState app_state,JSONObject src ){
Column( @NonNull AppState app_state, JSONObject src ){
this.app_state = app_state;
this.context = app_state.context;
@ -256,8 +281,8 @@ class Column {
this.header_fg_color = src.optInt( KEY_HEADER_TEXT_COLOR );
this.column_bg_color = src.optInt( KEY_COLUMN_BACKGROUND_COLOR );
this.column_bg_image = Utils.optStringX( src, KEY_COLUMN_BACKGROUND_IMAGE );
this.column_bg_image_alpha = (float)src.optDouble( KEY_COLUMN_BACKGROUND_IMAGE_ALPHA ,1.0f);
this.column_bg_image_alpha = (float) src.optDouble( KEY_COLUMN_BACKGROUND_IMAGE_ALPHA, 1.0f );
switch( column_type ){
case TYPE_CONVERSATION:
@ -334,6 +359,7 @@ class Column {
void dispose(){
is_dispose.set( true );
stopStreaming();
}
String getColumnName( boolean bLong ){
@ -471,7 +497,6 @@ class Column {
boolean bFirstInitialized = false;
private void init(){
bSimpleList = ( column_type != Column.TYPE_CONVERSATION && app_state.pref.getBoolean( Pref.KEY_SIMPLE_LIST, false ) );
}
@ -708,6 +733,7 @@ class Column {
String task_progress;
final BucketList< Object > list_data = new BucketList<>();
private final DuplicateMap duplicate_map = new DuplicateMap();
private static boolean hasMedia( TootStatus status ){
if( status == null ) return false;
@ -732,7 +758,7 @@ class Column {
for( Object o : list_data ){
if( o instanceof TootStatus ){
TootStatus item = (TootStatus) o;
if( item.checkMuted( muted_app,muted_word )){
if( item.checkMuted( muted_app, muted_word ) ){
continue;
}
@ -742,7 +768,7 @@ class Column {
TootStatus status = item.status;
if( status != null ){
if( status.checkMuted( muted_app,muted_word )){
if( status.checkMuted( muted_app, muted_word ) ){
continue;
}
}
@ -756,10 +782,12 @@ class Column {
}
}
@SuppressWarnings("ConstantConditions")
private void addWithFilter( ArrayList< Object > dst, TootStatus.List src ){
Pattern column_regex_filter = null;
private Pattern column_regex_filter;
private HashSet< String > muted_app;
private HashSet< String > muted_word;
private void initFilter(){
column_regex_filter = null;
if( ! TextUtils.isEmpty( regex_text ) ){
try{
column_regex_filter = Pattern.compile( regex_text );
@ -768,70 +796,84 @@ class Column {
}
}
HashSet< String > muted_app = MutedApp.getNameSet();
HashSet< String > muted_word = MutedWord.getNameSet();
for( TootStatus status : src ){
if( with_attachment ){
if( ! hasMedia( status ) && ! hasMedia( status.reblog ) ) continue;
}
if( dont_show_boost ){
if( status.reblog != null ) continue;
}
if( dont_show_reply ){
if( status.in_reply_to_id != null
|| ( status.reblog != null && status.reblog.in_reply_to_id != null )
) continue;
}
if( column_regex_filter != null ){
if( status.reblog != null ){
if( column_regex_filter.matcher( status.reblog.decoded_content.toString() ).find() )
continue;
}else{
if( column_regex_filter.matcher( status.decoded_content.toString() ).find() )
continue;
}
}
if( status.checkMuted( muted_app,muted_word )){
continue;
}
dst.add( status );
muted_app = MutedApp.getNameSet();
muted_word = MutedWord.getNameSet();
}
private boolean isFiltered( TootStatus status ){
if( with_attachment ){
if( ! hasMedia( status ) && ! hasMedia( status.reblog ) ) return true;
}
if( dont_show_boost ){
if( status.reblog != null ) return true;
}
if( dont_show_reply ){
if( status.in_reply_to_id != null
|| ( status.reblog != null && status.reblog.in_reply_to_id != null )
) return true;
}
if( column_regex_filter != null ){
if( status.reblog != null ){
if( column_regex_filter.matcher( status.reblog.decoded_content.toString() ).find() )
return true;
}else{
if( column_regex_filter.matcher( status.decoded_content.toString() ).find() )
return true;
}
}
//noinspection RedundantIfStatement
if( status.checkMuted( muted_app, muted_word ) ){
return true;
}
return false;
}
@SuppressWarnings("ConstantConditions")
private void addWithFilter( ArrayList< Object > dst, TootStatus.List src ){
for( TootStatus status : src ){
if( ! isFiltered( status ) ){
dst.add( status );
}
}
}
private boolean isFiltered( TootNotification item ){
TootStatus status = item.status;
if( status != null ){
if( status.checkMuted( muted_app, muted_word ) ){
log.d( "addWithFilter: status muted." );
return true;
}
}
return false;
}
@SuppressWarnings("ConstantConditions")
private void addWithFilter( ArrayList< Object > dst, TootNotification.List src ){
HashSet< String > muted_app = MutedApp.getNameSet();
HashSet< String > muted_word = MutedWord.getNameSet();
for( TootNotification item : src ){
TootStatus status = item.status;
if( status != null ){
if( status.checkMuted( muted_app,muted_word ) ){
log.d( "addWithFilter: status muted.");
continue;
}
if( ! isFiltered( item ) ){
dst.add( item );
}
dst.add( item );
}
}
void startLoading(){
cancelLastTask();
stopStreaming();
initFilter();
bFirstInitialized = true;
list_data.clear();
duplicate_map.clear();
mRefreshLoadingError = null;
bRefreshLoading = false;
mInitialLoadingError = null;
@ -1110,15 +1152,18 @@ class Column {
Column.this.mInitialLoadingError = result.error;
}else{
if( list_tmp != null ){
ArrayList< Object > list_new = duplicate_map.filterDuplicate( list_tmp );
list_data.clear();
list_data.addAll( list_tmp );
list_data.addAll( list_new );
}
resumeStreaming( false );
}
fireShowContent();
}
};
task.executeOnExecutor(App1.task_executor);
task.executeOnExecutor( App1.task_executor );
}
private static final Pattern reMaxId = Pattern.compile( "&max_id=(\\d+)" ); // より古いデータの取得に使う
@ -1297,6 +1342,8 @@ class Column {
}
}
boolean bRefreshingTop;
void startRefresh( final boolean bSilent, final boolean bBottom, final long status_id, final int refresh_after_toot ){
if( last_task != null ){
@ -1323,6 +1370,11 @@ class Column {
}
}
if(!bBottom){
bRefreshingTop = true;
stopStreaming();
}
bRefreshLoading = true;
mRefreshLoadingError = null;
@ -1736,114 +1788,91 @@ class Column {
@Override
protected void onPostExecute( TootApiResult result ){
if( isCancelled() || result == null ){
return;
}
last_task = null;
bRefreshLoading = false;
if( result.error != null ){
Column.this.mRefreshLoadingError = result.error;
fireShowContent();
return;
}
if( list_tmp == null || list_tmp.isEmpty() ){
fireShowContent();
return;
}
// 古いリストにある要素のIDの集合集合
HashSet< Long > set_status_id = new HashSet<>();
HashSet< Long > set_notification_id = new HashSet<>();
HashSet< Long > set_report_id = new HashSet<>();
HashSet< Long > set_account_id = new HashSet<>();
for( Object o : list_data ){
if( o instanceof TootStatus ){
set_status_id.add( ( (TootStatus) o ).id );
}else if( o instanceof TootNotification ){
set_notification_id.add( ( (TootNotification) o ).id );
}else if( o instanceof TootReport ){
set_report_id.add( ( (TootReport) o ).id );
}else if( o instanceof TootAccount ){
set_account_id.add( ( (TootAccount) o ).id );
}
}
ArrayList< Object > list_new = new ArrayList<>();
for( Object o : list_tmp ){
if( o instanceof TootStatus ){
if( set_status_id.contains( ( (TootStatus) o ).id ) ) continue;
}else if( o instanceof TootNotification ){
if( set_notification_id.contains( ( (TootNotification) o ).id ) )
continue;
}else if( o instanceof TootReport ){
if( set_report_id.contains( ( (TootReport) o ).id ) ) continue;
}else if( o instanceof TootAccount ){
if( set_account_id.contains( ( (TootAccount) o ).id ) ) continue;
}
list_new.add( o );
}
if( list_new.isEmpty() ){
fireShowContent();
return;
}
// 事前にスクロール位置を覚えておく
ScrollPosition sp = null;
if( holder != null ){
sp = holder.getScrollPosition();
}
if( bBottom ){
list_data.addAll( list_new );
fireShowContent();
try{
last_task = null;
bRefreshLoading = false;
if( sp != null ){
holder.setScrollPosition( sp, 20f );
if( result.error != null ){
Column.this.mRefreshLoadingError = result.error;
fireShowContent();
return;
}
if( list_tmp == null || list_tmp.isEmpty() ){
fireShowContent();
return;
}
}else{
int status_index = - 1;
for( int i = 0, ie = list_new.size() ; i < ie ; ++ i ){
Object o = list_new.get( i );
if( o instanceof TootStatus ){
TootStatus status = (TootStatus) o;
if( status.id == status_id ){
status_index = i;
break;
ArrayList< Object > list_new = duplicate_map.filterDuplicate( list_tmp );
if( list_new.isEmpty() ){
fireShowContent();
return;
}
// 事前にスクロール位置を覚えておく
ScrollPosition sp = null;
if( holder != null ){
sp = holder.getScrollPosition();
}
if( bBottom ){
list_data.addAll( list_new );
fireShowContent();
if( sp != null ){
holder.setScrollPosition( sp, 20f );
}
}else{
int status_index = - 1;
for( int i = 0, ie = list_new.size() ; i < ie ; ++ i ){
Object o = list_new.get( i );
if( o instanceof TootStatus ){
TootStatus status = (TootStatus) o;
if( status.id == status_id ){
status_index = i;
break;
}
}
}
int added = list_new.size();
list_data.addAll( 0, list_new );
fireShowContent();
if( status_index >= 0 && refresh_after_toot == Pref.RAT_REFRESH_SCROLL ){
if( holder != null ){
holder.setScrollPosition( new ScrollPosition( status_index, 0 ), 0f );
}else{
scroll_save = new ScrollPosition( status_index, 0 );
}
}else{
float delta = bSilent ? 0f : - 20f;
if( sp != null ){
sp.pos += added;
holder.setScrollPosition( sp, delta );
}else if( scroll_save != null ){
scroll_save.pos += added;
}else{
scroll_save = new ScrollPosition( added, 0 );
}
}
}
int added = list_new.size();
list_data.addAll( 0, list_new );
fireShowContent();
if( status_index >= 0 && refresh_after_toot == Pref.RAT_REFRESH_SCROLL ){
if( holder != null ){
holder.setScrollPosition( new ScrollPosition( status_index, 0 ), 0f );
}else{
scroll_save = new ScrollPosition( status_index, 0 );
}
}else{
float delta = bSilent ? 0f : - 20f;
if( sp != null ){
sp.pos += added;
holder.setScrollPosition( sp, delta );
}else if( scroll_save != null ){
scroll_save.pos += added;
}else{
scroll_save = new ScrollPosition( added, 0 );
}
}finally{
if(!bBottom){
bRefreshingTop = false;
resumeStreaming( false );
}
}
}
};
task.executeOnExecutor(App1.task_executor);
task.executeOnExecutor( App1.task_executor );
}
void startGap( final TootGap gap, final int position ){
if( last_task != null ){
Utils.showToast( context, true, R.string.column_is_busy );
@ -2178,39 +2207,9 @@ class Column {
fireShowContent();
return;
}
// 0個でもギャップを消すために以下の処理を続ける
// 古いリストにある要素のIDの集合
HashSet< Long > set_status_id = new HashSet<>();
HashSet< Long > set_notification_id = new HashSet<>();
HashSet< Long > set_report_id = new HashSet<>();
HashSet< Long > set_account_id = new HashSet<>();
for( Object o : list_data ){
if( o instanceof TootStatus ){
set_status_id.add( ( (TootStatus) o ).id );
}else if( o instanceof TootNotification ){
set_notification_id.add( ( (TootNotification) o ).id );
}else if( o instanceof TootReport ){
set_report_id.add( ( (TootReport) o ).id );
}else if( o instanceof TootAccount ){
set_account_id.add( ( (TootAccount) o ).id );
}
}
// list_tmp をフィルタしてlist_newを作成
ArrayList< Object > list_new = new ArrayList<>();
for( Object o : list_tmp ){
if( o instanceof TootStatus ){
if( set_status_id.contains( ( (TootStatus) o ).id ) ) continue;
}else if( o instanceof TootNotification ){
if( set_notification_id.contains( ( (TootNotification) o ).id ) )
continue;
}else if( o instanceof TootReport ){
if( set_report_id.contains( ( (TootReport) o ).id ) ) continue;
}else if( o instanceof TootAccount ){
if( set_account_id.contains( ( (TootAccount) o ).id ) )
continue;
}
list_new.add( o );
}
ArrayList< Object > list_new = duplicate_map.filterDuplicate( list_tmp );
// idx番目の要素がListViewのtopから何ピクセル下にあるか
int restore_idx = position + 1;
@ -2223,7 +2222,7 @@ class Column {
try{
restore_y = getItemTop( restore_idx );
}catch( IndexOutOfBoundsException ex2 ){
restore_idx = -1;
restore_idx = - 1;
}
}
}
@ -2235,7 +2234,7 @@ class Column {
if( holder != null ){
//noinspection StatementWithEmptyBody
if(restore_idx >= 0 ){
if( restore_idx >= 0 ){
setItemTop( restore_idx + added - 1, restore_y );
}else{
// ギャップが画面内にない場合何もしない
@ -2248,7 +2247,7 @@ class Column {
}
};
task.executeOnExecutor(App1.task_executor);
task.executeOnExecutor( App1.task_executor );
}
private static final int heightSpec = View.MeasureSpec.makeMeasureSpec( 0, View.MeasureSpec.UNSPECIFIED );
@ -2296,4 +2295,296 @@ class Column {
return listView.getChildAt( child_idx ).getTop();
}
final StreamReader.Callback stream_callback = new StreamReader.Callback() {
@Override public void onEvent( String event_type, Object o ){
if( o instanceof Long ){
removeStatus( access_info, (Long) o );
return;
}
if( o instanceof TootNotification ){
TootNotification notification = (TootNotification) o;
if( column_type != TYPE_NOTIFICATIONS ) return;
if( isFiltered( notification ) ) return;
}else if( o instanceof TootStatus ){
TootStatus status = (TootStatus) o;
if( column_type == TYPE_NOTIFICATIONS ) return;
if( column_type == TYPE_LOCAL && status.account.acct.indexOf( '@' ) != - 1 ) return;
if( isFiltered( status ) ) return;
}
stream_data_queue.addFirst( o );
proc_stream_data.run();
}
};
private final LinkedList< Object > stream_data_queue = new LinkedList<>();
// ListViewの表示更新が追いつかないとスクロール位置が崩れるので
// 一定時間より短期間にはデータ更新しないようにする
private long last_show_stream_data;
final Runnable proc_stream_data = new Runnable() {
@Override public void run(){
App1.getAppState( context ).handler.removeCallbacks( proc_stream_data );
long now = SystemClock.elapsedRealtime();
long remain = last_show_stream_data + 333L - now;
if( remain > 0 ){
App1.getAppState( context ).handler.postDelayed( proc_stream_data, 333L );
return;
}
last_show_stream_data = now;
ArrayList< Object > list_new = duplicate_map.filterDuplicate( stream_data_queue );
stream_data_queue.clear();
if( list_new.isEmpty() ){
return;
}else{
if( column_type == TYPE_NOTIFICATIONS ){
TootNotification.List list = new TootNotification.List( );
for( Object o : list_new ){
if( o instanceof TootNotification){
list.add( (TootNotification) o);
}
}
if( !list.isEmpty() ){
AlarmService.injectData( context, access_info.db_id, list );
}
}
try{
since_id = Long.toString( getId( list_new.get( 0 ) ) );
}catch(Throwable ex){
// ストリームに来るのは通知かステータスだから多分ここは通らない
log.e(ex,"getId() failed. o=",list_new.get( 0 ));
}
}
// 事前にスクロール位置を覚えておく
ScrollPosition sp = null;
if( holder != null ){
sp = holder.getScrollPosition();
}
// idx番目の要素がListViewのtopから何ピクセル下にあるか
int restore_idx = - 1;
int restore_y = 0;
if( holder != null ){
if( list_data.size() > 0 ){
try{
restore_idx = holder.getListView().getFirstVisiblePosition();
restore_y = getItemTop( restore_idx );
}catch( IndexOutOfBoundsException ex ){
ex.printStackTrace();
}
}
}
if( bPutGap ){
bPutGap = false;
try{
if( list_new.size() > 0 && list_data.size() > 0 ){
long max = getId( list_new.get( list_new.size() - 1 ) );
long since = getId( list_data.get( 0 ) );
if( max > since ){
TootGap gap = new TootGap( max,since );
list_new.add( gap );
}
}
}catch( Throwable ex ){
log.e( ex, "can't put gap." );
}
}
list_data.addAll( 0, list_new );
fireShowContent();
int added = list_new.size();
if( holder != null ){
//noinspection StatementWithEmptyBody
if( sp == null || ( sp.pos == 0 && sp.top == 0 ) ){
// スクロール位置が先頭なら先頭のまま
}else if( restore_idx >= 0 ){
//
setItemTop( restore_idx + added, restore_y );
}else{
// ギャップが画面内にない場合何もしない
}
}else{
if( scroll_save == null || ( scroll_save.pos == 0 || scroll_save.top == 0 ) ){
// スクロール位置が先頭なら先頭のまま
}else{
// 現在の要素が表示され続けるようにしたい
scroll_save.pos += added;
}
}
}
};
private long getId( Object o ){
if( o instanceof TootNotification ){
return ( (TootNotification) o ).id;
}else if( o instanceof TootStatus ){
return ( (TootStatus) o ).id;
}else if( o instanceof TootAccount){
return ( (TootAccount) o ).id;
}
throw new RuntimeException( "getId: object is not status,notification" );
}
// onPauseの時はまとめて止められるが
// カラム破棄やリロード開始時は個別にストリーミングを止める必要がある
private void stopStreaming(){
switch( column_type ){
case TYPE_HOME:
case TYPE_NOTIFICATIONS:
app_state.stream_reader.unregister(
access_info
, StreamReader.EP_USER
, stream_callback
);
break;
case TYPE_LOCAL:
case TYPE_FEDERATE:
app_state.stream_reader.unregister(
access_info
, StreamReader.EP_PUBLIC
, stream_callback
);
break;
case TYPE_HASHTAG:
app_state.stream_reader.unregister(
access_info
, StreamReader.EP_HASHTAG + "?tag=" + Uri.encode( hashtag )
, stream_callback
);
break;
}
}
void onResume( Callback callback ){
this.callback_ref = new WeakReference<>( callback );
// 破棄されたカラムなら何もしない
if( is_dispose.get() ){
log.d("onResume: column was disposed.");
return;
}
// 未初期化なら何もしない
if( ! bFirstInitialized ){
log.d("onResume: column is not initialized.");
return;
}
// 初期ロード中なら何もしない
if( bInitialLoading ){
log.d("onResume: column is in initial loading.");
return;
}
if( bRefreshingTop ){
log.d("onResume: bRefreshingTop is true.");
// リフレッシュ終了時に自動でストリーミング開始するはず
}else if( !bRefreshLoading
&& ! App1.getAppState( context ).pref.getBoolean(Pref.KEY_DONT_REFRESH_ON_RESUME,false)
){
log.d("onResume: start auto refresh.");
// リフレッシュしてからストリーミング開始
startRefresh( true, false, - 1L, - 1 );
}else{
log.d("onResume: start streaming with gap.");
// ギャップつきでストリーミング開始
resumeStreaming( true );
}
}
boolean bPutGap;
private void resumeStreaming( boolean bPutGap ){
if( ! isResume() ){
log.d("resumeStreaming: not resumed.");
return;
}
// 破棄されたカラムなら何もしない
if( is_dispose.get() ){
log.d("resumeStreaming: column was disposed.");
return;
}
// 未初期化なら何もしない
if( ! bFirstInitialized ){
log.d("resumeStreaming: column is not initialized.");
return;
}
// 初期ロード中なら何もしない
if( bInitialLoading ){
log.d("resumeStreaming: is in initial loading.");
return;
}
this.bPutGap = bPutGap;
stream_data_queue.clear();
switch( column_type ){
case TYPE_HOME:
case TYPE_NOTIFICATIONS:
if( access_info.isPseudo() ) return;
app_state.stream_reader.register(
access_info
, StreamReader.EP_USER
, stream_callback
);
break;
case TYPE_LOCAL:
// 認証がないと読めないらしい
if( access_info.isPseudo() ) return;
app_state.stream_reader.register(
access_info
, StreamReader.EP_PUBLIC_LOCAL
, stream_callback
);
break;
case TYPE_FEDERATE:
// 認証がないと読めないらしい
if( access_info.isPseudo() ) return;
app_state.stream_reader.register(
access_info
, StreamReader.EP_PUBLIC
, stream_callback
);
break;
case TYPE_HASHTAG:
app_state.stream_reader.register(
access_info
, StreamReader.EP_HASHTAG + "&tag=" + Uri.encode( hashtag )
, stream_callback
);
break;
}
}
}

View File

@ -47,7 +47,6 @@ class ColumnPagerAdapter extends PagerAdapter {
pager.setAdapter( null );
column_list.add( index, column );
pager.setAdapter( this );
notifyDataSetChanged();
return index;
}
@ -55,9 +54,8 @@ class ColumnPagerAdapter extends PagerAdapter {
int idx_column = column_list.indexOf( column );
if( idx_column == - 1 ) return;
pager.setAdapter( null );
column_list.remove( idx_column );
column_list.remove( idx_column ).dispose();
pager.setAdapter( this );
column.dispose();
}
void setOrder( ViewPager pager, ArrayList< Integer > order ){

View File

@ -1,8 +1,6 @@
package jp.juggler.subwaytooter;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.v4.view.ViewCompat;
@ -430,7 +428,7 @@ class ColumnViewHolder
public void onClick( View v ){
switch( v.getId() ){
case R.id.btnColumnClose:
activity.performColumnClose( false, column );
activity.closeColumn( false, column );
break;
case R.id.btnColumnReload:

View File

@ -37,6 +37,9 @@ public class Pref {
static final String KEY_FOOTER_TAB_BG_COLOR = "footer_tab_bg_color";
static final String KEY_FOOTER_TAB_DIVIDER_COLOR = "footer_tab_divider_color";
static final String KEY_DONT_USE_STREAMING = "dont_use_streaming";
static final String KEY_DONT_REFRESH_ON_RESUME = "dont_refresh_on_resume";
}

View File

@ -0,0 +1,282 @@
package jp.juggler.subwaytooter;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.Handler;
import android.support.annotation.NonNull;
import org.json.JSONObject;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import jp.juggler.subwaytooter.api.TootApiClient;
import jp.juggler.subwaytooter.api.TootApiResult;
import jp.juggler.subwaytooter.api.entity.TootNotification;
import jp.juggler.subwaytooter.api.entity.TootStatus;
import jp.juggler.subwaytooter.table.SavedAccount;
import jp.juggler.subwaytooter.util.LogCategory;
import jp.juggler.subwaytooter.util.Utils;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
class StreamReader {
static final LogCategory log = new LogCategory( "StreamReader" );
static final String EP_USER = "/api/v1/streaming/?stream=user";
static final String EP_PUBLIC = "/api/v1/streaming/?stream=public";
static final String EP_PUBLIC_LOCAL = "/api/v1/streaming/?stream=public:local";
static final String EP_HASHTAG = "/api/v1/streaming/?stream=hashtag"; // + &tag=hashtag (先頭のを含まない)
interface Callback {
void onEvent( String event_type, Object o );
}
private class Reader extends WebSocketListener {
final SavedAccount access_info;
final String end_point;
final LinkedList< Callback > callback_list = new LinkedList<>();
Reader( SavedAccount access_info, String end_point ){
this.access_info = access_info;
this.end_point = end_point;
}
synchronized void addCallback( @NonNull Callback stream_callback ){
for( Callback c : callback_list ){
if( c == stream_callback ) return;
}
callback_list.add( stream_callback );
}
synchronized void removeCallback( Callback stream_callback ){
Iterator< Callback > it = callback_list.iterator();
while( it.hasNext() ){
Callback c = it.next();
if( c == stream_callback ) it.remove();
}
}
final AtomicBoolean bDisposed = new AtomicBoolean();
final AtomicBoolean bListening = new AtomicBoolean();
final AtomicReference< WebSocket > socket = new AtomicReference<>( null );
void dispose(){
bDisposed.set( true );
WebSocket ws = socket.get();
if( ws != null ){
ws.cancel();
}
}
/**
* Invoked when a web socket has been accepted by the remote peer and may begin transmitting
* messages.
*/
@Override
public void onOpen( WebSocket webSocket, Response response ){
log.d( "WebSocket onOpen. url=%s", webSocket.request().url() );
}
/**
* Invoked when a text (type {@code 0x1}) message has been received.
*/
@Override
public void onMessage( WebSocket webSocket, String text ){
// log.d( "WebSocket onMessage. url=%s, message=%s", webSocket.request().url(), text );
try{
final JSONObject obj = new JSONObject( text );
final String event = obj.optString( "event" );
final Object payload = parsePayload( event, obj );
if( payload != null ){
Utils.runOnMainThread( new Runnable() {
@Override public void run(){
if( bDisposed.get() ) return;
synchronized( this ){
for( Callback callback : callback_list ){
try{
callback.onEvent( event, payload );
}catch( Throwable ex ){
ex.printStackTrace();
}
}
}
}
} );
}
}catch( Throwable ex ){
ex.printStackTrace();
}
}
private Object parsePayload( String event, JSONObject obj ){
try{
if( "update".equals( event ) ){
return TootStatus.parse( log, access_info, new JSONObject( obj.optString( "payload" ) ) );
}else if( "notification".equals( event ) ){
return TootNotification.parse( log, access_info, new JSONObject( obj.optString( "payload" ) ) );
}else if( "delete".equals( event ) ){
return obj.optLong( "payload", - 1L );
}
}catch( Throwable ex ){
ex.printStackTrace();
}
return null;
}
/**
* Invoked when the peer has indicated that no more incoming messages will be transmitted.
*/
@Override
public void onClosing( WebSocket webSocket, int code, String reason ){
log.d( "WebSocket onClosing. code=%s,reason=%s,url=%s", code, reason, webSocket.request().url() );
webSocket.cancel();
bListening.set( false );
handler.removeCallbacks( proc_reconnect );
handler.postDelayed( proc_reconnect, 10000L );
}
/**
* Invoked when both peers have indicated that no more messages will be transmitted and the
* connection has been successfully released. No further calls to this listener will be made.
*/
@Override
public void onClosed( WebSocket webSocket, int code, String reason ){
log.d( "WebSocket onClosed. code=%s,reason=%s,url=%s", code, reason, webSocket.request().url() );
bListening.set( false );
handler.removeCallbacks( proc_reconnect );
handler.postDelayed( proc_reconnect, 10000L );
}
/**
* Invoked when a web socket has been closed due to an error reading from or writing to the
* network. Both outgoing and incoming messages may have been lost. No further calls to this
* listener will be made.
*/
@Override
public void onFailure( WebSocket webSocket, Throwable ex, Response response ){
log.e( ex, "WebSocket onFailure. url=%s", webSocket.request().url() );
bListening.set( false );
handler.removeCallbacks( proc_reconnect );
handler.postDelayed( proc_reconnect, 10000L );
}
final Runnable proc_reconnect = new Runnable() {
@Override public void run(){
if( bDisposed.get() ) return;
startRead();
}
};
void startRead(){
if( bDisposed.get() ){
log.d( "startRead: this reader is disposed." );
return;
}else if( bListening.get() ){
log.d( "startRead: this reader is already listening." );
return;
}
bListening.set( true );
new AsyncTask< Void, Void, TootApiResult >() {
@Override protected TootApiResult doInBackground( Void... params ){
TootApiClient client = new TootApiClient( context, new TootApiClient.Callback() {
@Override public boolean isApiCancelled(){
return isCancelled();
}
@Override public void publishApiProgress( String s ){
}
} );
client.setAccount( access_info );
TootApiResult result = client.webSocket( end_point, new Request.Builder(), Reader.this );
if( result == null ){
log.d( "startRead: cancelled." );
bListening.set( false );
}else{
socket.set( result.socket );
}
return result;
}
}.executeOnExecutor( App1.task_executor );
}
}
private final LinkedList< Reader > reader_list = new LinkedList<>();
final Context context;
final SharedPreferences pref;
private final Handler handler;
StreamReader( Context context, SharedPreferences pref ){
this.context = context;
this.pref = pref;
this.handler = new Handler();
}
private Reader prepareReader( @NonNull SavedAccount access_info, @NonNull String end_point ){
synchronized( reader_list ){
for( Reader reader : reader_list ){
if( reader.access_info.db_id == access_info.db_id
&& reader.end_point.equals( end_point )
){
return reader;
}
}
Reader reader = new Reader( access_info, end_point );
reader_list.add( reader );
return reader;
}
}
// onPauseのタイミングで全てのStreaming接続を破棄する
void onPause(){
synchronized( reader_list ){
for( Reader reader : reader_list ){
reader.dispose();
}
reader_list.clear();
}
}
// カラム破棄やリロードのタイミングで呼ばれる
void unregister( SavedAccount access_info, String end_point, Callback stream_callback ){
synchronized( reader_list ){
Iterator< Reader > it = reader_list.iterator();
while( it.hasNext() ){
Reader reader = it.next();
if( reader.access_info.db_id == access_info.db_id
&& reader.end_point.equals( end_point )
){
reader.removeCallback( stream_callback );
if( reader.callback_list.isEmpty() ){
reader.dispose();
it.remove();
}
}
}
}
}
// onResume ロード完了ののタイミングで登録される
void register( @NonNull SavedAccount access_info, @NonNull String end_point, @NonNull Callback stream_callback ){
if( pref.getBoolean( Pref.KEY_DONT_USE_STREAMING ,false) ) return;
final Reader reader = prepareReader( access_info, end_point );
reader.addCallback( stream_callback );
if( ! reader.bListening.get() ){
reader.startRead();
}
}
}

View File

@ -20,6 +20,8 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public class TootApiClient {
private static final LogCategory log = new LogCategory( "TootApiClient" );
@ -141,6 +143,46 @@ public class TootApiClient {
}
public TootApiResult webSocket( String path, Request.Builder request_builder , WebSocketListener ws_listener ){
if( callback.isApiCancelled() ) return null;
// アクセストークンを使ってAPIを呼び出す
callback.publishApiProgress( context.getString( R.string.request_api, path ) );
if( account == null ){
return new TootApiResult( "account is null" );
}
String url = "wss://" + instance + path;
JSONObject token_info = account.token_info;
String access_token = Utils.optStringX( token_info, "access_token" );
if( !TextUtils.isEmpty( access_token ) ){
char delm = (-1!= url.indexOf( '?' ) ? '&':'?');
url = url + delm + "access_token="+ access_token;
}
request_builder.url( url );
try{
WebSocket ws = ok_http_client.newWebSocket( request_builder.build() ,ws_listener );
if( callback.isApiCancelled() ){
ws.cancel();
return null;
}
return new TootApiResult( ws );
}catch( Throwable ex ){
ex.printStackTrace( );
return new TootApiResult(
Utils.formatError( ex, context.getResources(), R.string.network_error )
);
}
}
// 疑似アカウントの追加時にインスタンスの検証を行う
public TootApiResult checkInstance(){

View File

@ -10,6 +10,7 @@ import java.util.regex.Pattern;
import jp.juggler.subwaytooter.util.LogCategory;
import okhttp3.Response;
import okhttp3.WebSocket;
public class TootApiResult {
public String error;
@ -18,19 +19,21 @@ public class TootApiResult {
public String json;
public JSONObject token_info;
public Response response;
public WebSocket socket;
public TootApiResult( String error ){
this.error = error;
}
public TootApiResult( Response response,JSONObject token_info,String json,JSONObject object ){
TootApiResult( Response response, JSONObject token_info, String json, JSONObject object ){
this.token_info = token_info;
this.json = json;
this.object = object;
this.response = response;
}
public TootApiResult( LogCategory log, Response response, JSONObject token_info
TootApiResult( LogCategory log, Response response, JSONObject token_info
, String json, JSONArray array ){
this.token_info = token_info;
this.json = json;
@ -39,6 +42,10 @@ public class TootApiResult {
parseLinkHeader(log,response,array);
}
TootApiResult( WebSocket socket ){
this.socket = socket;
}
public String link_older; // より古いデータへのリンク
public String link_newer; // より新しいデータへの

View File

@ -11,4 +11,8 @@ public class TootGap {
this.max_id = Long.toString(max_id);
this.since_id = since_id;
}
public TootGap( long max_id, long since_id ){
this.max_id = Long.toString(max_id);
this.since_id = Long.toString(since_id);
}
}

View File

@ -26,7 +26,6 @@ public class BucketList < E >
this( DEFAULT_BUCKET_CAPACITY );
}
private static class Bucket < E > extends ArrayList< E > {
int total_start;
int total_end;
@ -67,7 +66,7 @@ public class BucketList < E >
return new BucketPos();
}
};
private BucketPos findPos( BucketPos dst, int total_index ){
if( total_index < 0 || total_index >= mSize ){
@ -139,7 +138,7 @@ public class BucketList < E >
BucketPos pos = findPos( pos_internal.get(), index );
Bucket< E > bucket = groups.get( pos.group_index );
// 挿入位置がバケツの先頭ではないかバケツのサイズに問題がないなら
if( pos.bucket_index > 0 || bucket.size() + c_size <= mStep ){
// バケツの中に挿入する
@ -150,22 +149,22 @@ public class BucketList < E >
bucket.addAll( c );
groups.add( pos.group_index, bucket );
}
updateIndex();
return true;
}
public E remove( int index ){
BucketPos pos = findPos( pos_internal.get(), index );
Bucket< E > bucket = groups.get( pos.group_index );
E data = bucket.remove( pos.bucket_index );
if( bucket.isEmpty() ){
groups.remove( pos.group_index );
}
updateIndex();
return data;
}

View File

@ -0,0 +1,63 @@
package jp.juggler.subwaytooter.util;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import jp.juggler.subwaytooter.api.entity.TootAccount;
import jp.juggler.subwaytooter.api.entity.TootNotification;
import jp.juggler.subwaytooter.api.entity.TootReport;
import jp.juggler.subwaytooter.api.entity.TootStatus;
/**
* Created by tateisu on 2017/05/10.
*/
public class DuplicateMap {
HashSet< Long > set_status_id = new HashSet<>();
HashSet< Long > set_notification_id = new HashSet<>();
HashSet< Long > set_report_id = new HashSet<>();
HashSet< Long > set_account_id = new HashSet<>();
public void clear(){
set_status_id.clear();
set_notification_id.clear();
set_report_id.clear();
set_account_id.clear();
}
boolean isDuplicate(Object o){
if( o instanceof TootStatus ){
if( set_status_id.contains( ( (TootStatus) o ).id ) ) return true;
set_status_id.add( ( (TootStatus) o ).id );
}else if( o instanceof TootNotification ){
if( set_notification_id.contains( ( (TootNotification) o ).id ) ) return true;
set_notification_id.add( ( (TootNotification) o ).id );
}else if( o instanceof TootReport ){
if( set_report_id.contains( ( (TootReport) o ).id ) ) return true;
set_report_id.add( ( (TootReport) o ).id );
}else if( o instanceof TootAccount ){
if( set_account_id.contains( ( (TootAccount) o ).id ) ) return true;
set_account_id.add( ( (TootAccount) o ).id );
}
return false;
}
public ArrayList<Object> filterDuplicate( Collection< Object > src ){
ArrayList< Object > list_new = new ArrayList<>();
for( Object o : src ){
if( isDuplicate( o )) continue;
list_new.add( o );
}
return list_new;
}
}

View File

@ -26,6 +26,40 @@
<View style="@style/setting_divider"/>
<TextView
style="@style/setting_row_label"
android:text="@string/dont_use_streaming_api"
/>
<LinearLayout style="@style/setting_row_form">
<Switch
android:id="@+id/swDontUseStreaming"
style="@style/setting_horizontal_stretch"
android:gravity="center"
/>
</LinearLayout>
<View style="@style/setting_divider"/>
<TextView
style="@style/setting_row_label"
android:text="@string/dont_refresh_on_activity_resume"
/>
<LinearLayout style="@style/setting_row_form">
<Switch
android:id="@+id/swDontRefreshOnResume"
style="@style/setting_horizontal_stretch"
android:gravity="center"
/>
</LinearLayout>
<View style="@style/setting_divider"/>
<TextView
style="@style/setting_row_label"
android:text="@string/dont_confirm_before_close_column"

View File

@ -302,5 +302,7 @@
<string name="tab_divider_color">Tab divider color</string>
<string name="help_translation">Help translation</string>
<string name="open_status_from">Open status from</string>
<string name="dont_use_streaming_api">Don\'t use streaming API</string>
<string name="dont_refresh_on_activity_resume">Don\'t auto refresh when activity resumed</string>
</resources>

View File

@ -588,4 +588,6 @@
<!--ステータスURLをアプリ内で開く時に、どのアカウントから閲覧するかで取得結果が異なる。ログインなしの擬似アカウントも選択肢に含まれる。-->
<string name="open_status_from">ステータスを次のアカウントから開く</string>
<string name="help_translation">翻訳に協力する</string>
<string name="dont_use_streaming_api">ストリーミングAPIを使わない</string>
<string name="dont_refresh_on_activity_resume">画面復帰時に自動ロードしない</string>
</resources>

View File

@ -298,4 +298,6 @@
<string name="tab_divider_color">Tab divider color</string>
<string name="open_status_from">Open status from</string>
<string name="help_translation">Help translation</string>
<string name="dont_use_streaming_api">Don\'t use streaming API</string>
<string name="dont_refresh_on_activity_resume">Don\'t auto refresh when activity resumed</string>
</resources>

View File

@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
classpath 'com.android.tools.build:gradle:2.3.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files