SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.java

695 lines
22 KiB
Java

package jp.juggler.subwaytooter.util;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jp.juggler.subwaytooter.App1;
import jp.juggler.subwaytooter.Pref;
import jp.juggler.subwaytooter.R;
import jp.juggler.subwaytooter.api.TootApiClient;
import jp.juggler.subwaytooter.api.TootApiResult;
import jp.juggler.subwaytooter.api.TootTask;
import jp.juggler.subwaytooter.api.TootTaskRunner;
import jp.juggler.subwaytooter.api.entity.CustomEmoji;
import jp.juggler.subwaytooter.api.entity.TootAccount;
import jp.juggler.subwaytooter.api.entity.TootInstance;
import jp.juggler.subwaytooter.api.entity.TootStatus;
import jp.juggler.subwaytooter.api.TootParser;
import jp.juggler.subwaytooter.dialog.DlgConfirm;
import jp.juggler.subwaytooter.dialog.EmojiPicker;
import jp.juggler.subwaytooter.table.AcctColor;
import jp.juggler.subwaytooter.table.AcctSet;
import jp.juggler.subwaytooter.table.SavedAccount;
import jp.juggler.subwaytooter.table.TagSet;
import jp.juggler.subwaytooter.view.MyEditText;
import okhttp3.Request;
import okhttp3.RequestBody;
public class PostHelper implements CustomEmojiLister.Callback, EmojiPicker.Callback {
private static final LogCategory log = new LogCategory( "PostHelper" );
public interface Callback {
void onPostComplete( SavedAccount target_account, TootStatus status );
}
private final AppCompatActivity activity;
private final SharedPreferences pref;
private final Handler handler;
private final String picker_caption_emoji;
// private final String picker_caption_tag;
// private final String picker_caption_mention;
public PostHelper( AppCompatActivity activity, SharedPreferences pref, Handler handler ){
this.activity = activity;
this.pref = pref;
this.handler = handler;
this.picker_caption_emoji = activity.getString( R.string.open_picker_emoji );
// this.picker_caption_tag = activity.getString( R.string.open_picker_tag );
// this.picker_caption_mention = activity.getString( R.string.open_picker_mention );
}
// [:word:] 単語構成文字 (Letter | Mark | Decimal_Number | Connector_Punctuation)
// [:alpha:] 英字 (Letter | Mark)
private static final String word = "[_\\p{L}\\p{M}\\p{Nd}\\p{Pc}]";
private static final String alpha = "[_\\p{L}\\p{M}]";
private static final Pattern reTag = Pattern.compile(
"(?:^|[^/)\\w])#(" + word + "*" + alpha + word + "*)"
, Pattern.CASE_INSENSITIVE
);
private static final Pattern reCharsNotTag = Pattern.compile( "[・\\s\\-+.,:;/]" );
private static final Pattern reCharsNotEmoji = Pattern.compile( "[^0-9A-Za-z_-]" );
public String content;
public String spoiler_text;
public String visibility;
public boolean bNSFW;
public long in_reply_to_id;
public ArrayList< PostAttachment > attachment_list;
public ArrayList< String > enquete_items;
private static final VersionString version_1_6 = new VersionString( "1.6" );
public void post( final SavedAccount account, final boolean bConfirmTag, final boolean bConfirmAccount, final Callback callback ){
if( TextUtils.isEmpty( content ) ){
Utils.showToast( activity, true, R.string.post_error_contents_empty );
return;
}
if( spoiler_text != null && spoiler_text.isEmpty() ){
Utils.showToast( activity, true, R.string.post_error_contents_warning_empty );
return;
}
if( enquete_items != null && ! enquete_items.isEmpty() ){
for( int n = 0, ne = enquete_items.size() ; n < ne ; ++ n ){
String item = enquete_items.get( n );
if( TextUtils.isEmpty( item ) ){
if( n < 2 ){
Utils.showToast( activity, true, R.string.enquete_item_is_empty, n + 1 );
return;
}
}else{
int code_count = item.codePointCount( 0, item.length() );
if( code_count > 15 ){
int over = code_count - 15;
Utils.showToast( activity, true, R.string.enquete_item_too_long, n + 1, over );
return;
}else if( n > 0 ){
for( int i = 0 ; i < n ; ++ i ){
if( item.equals( enquete_items.get( i ) ) ){
Utils.showToast( activity, true, R.string.enquete_item_duplicate, n + 1 );
return;
}
}
}
}
}
}
if( ! bConfirmAccount ){
DlgConfirm.open( activity
, activity.getString( R.string.confirm_post_from, AcctColor.getNickname( account.acct ) )
, new DlgConfirm.Callback() {
@Override public boolean isConfirmEnabled(){
return account.confirm_post;
}
@Override public void setConfirmEnabled( boolean bv ){
account.confirm_post = bv;
account.saveSetting();
}
@Override public void onOK(){
post( account, bConfirmTag, true, callback );
}
} );
return;
}
if( ! bConfirmTag ){
Matcher m = reTag.matcher( content );
if( m.find() && ! TootStatus.VISIBILITY_PUBLIC.equals( visibility ) ){
new AlertDialog.Builder( activity )
.setCancelable( true )
.setMessage( R.string.hashtag_and_visibility_not_match )
.setNegativeButton( R.string.cancel, null )
.setPositiveButton( R.string.ok, new DialogInterface.OnClickListener() {
@Override public void onClick( DialogInterface dialog, int which ){
//noinspection ConstantConditions
post( account, true, bConfirmAccount, callback );
}
} )
.show();
return;
}
}
new TootTaskRunner( activity, true ).run( account, new TootTask() {
final SavedAccount target_account = account;
TootStatus status;
TootInstance instance_tmp;
TootApiResult getInstanceInformation( @NonNull TootApiClient client ){
instance_tmp = null;
TootApiResult result = client.request( "/api/v1/instance" );
if( result != null && result.object != null ){
instance_tmp = TootInstance.parse( result.object );
}
return result;
}
TootAccount credential_tmp;
TootApiResult getCredential( @NonNull TootApiClient client ){
credential_tmp = null;
TootApiResult result = client.request( "/api/v1/accounts/verify_credentials" );
if( result != null && result.object != null ){
credential_tmp = TootAccount.parse( activity, target_account, result.object );
}
return result;
}
@Override public TootApiResult background( @NonNull TootApiClient client ){
if( TextUtils.isEmpty( visibility ) ){
visibility = TootStatus.VISIBILITY_PUBLIC;
}
String visibility_checked = visibility;
if( TootStatus.VISIBILITY_WEB_SETTING.equals( visibility ) ){
TootInstance instance = target_account.getInstance();
if( instance == null ){
TootApiResult r2 = getInstanceInformation( client );
if( instance_tmp == null ) return r2;
instance = instance_tmp;
target_account.setInstance( instance_tmp );
}
if( instance.isEnoughVersion( version_1_6 ) ){
visibility_checked = null;
}else{
TootApiResult r2 = getCredential( client );
if( credential_tmp == null ){
return r2;
}else if( credential_tmp.source == null || credential_tmp.source.privacy == null ){
return new TootApiResult( activity.getString( R.string.cant_get_web_setting_visibility ) );
}else{
visibility_checked = credential_tmp.source.privacy;
}
}
}
RequestBody request_body;
String body_string;
if( enquete_items == null || enquete_items.isEmpty() ){
StringBuilder sb = new StringBuilder();
sb.append( "status=" );
sb.append( Uri.encode( EmojiDecoder.decodeShortCode(content ) ) );
if( visibility_checked != null ){
sb.append( "&visibility=" );
sb.append( Uri.encode( visibility_checked ) );
}
if( bNSFW ){
sb.append( "&sensitive=1" );
}
if( spoiler_text != null ){
sb.append( "&spoiler_text=" );
sb.append( Uri.encode( EmojiDecoder.decodeShortCode(spoiler_text) ) );
}
if( in_reply_to_id != - 1L ){
sb.append( "&in_reply_to_id=" );
sb.append( Long.toString( in_reply_to_id ) );
}
if( attachment_list != null ){
for( PostAttachment pa : attachment_list ){
if( pa.attachment != null ){
sb.append( "&media_ids[]=" ).append( pa.attachment.id );
}
}
}
body_string = sb.toString();
request_body = RequestBody.create(
TootApiClient.MEDIA_TYPE_FORM_URL_ENCODED
, body_string
);
}else{
JSONObject json = new JSONObject();
try{
json.put( "status", EmojiDecoder.decodeShortCode(content) );
if( visibility_checked != null ){
json.put( "visibility", visibility_checked );
}
json.put( "sensitive", bNSFW );
json.put( "spoiler_text", TextUtils.isEmpty( spoiler_text ) ? "" : EmojiDecoder.decodeShortCode(spoiler_text) );
json.put( "in_reply_to_id", in_reply_to_id == - 1L ? null : in_reply_to_id );
JSONArray array = new JSONArray();
if( attachment_list != null ){
for( PostAttachment pa : attachment_list ){
if( pa.attachment != null ){
array.put( pa.attachment.id );
}
}
}
json.put( "media_ids", array );
json.put( "isEnquete", true );
array = new JSONArray();
for( String item : enquete_items ){
array.put( EmojiDecoder.decodeShortCode(item) );
}
json.put( "enquete_items", array );
}catch( JSONException ex ){
log.trace( ex );
log.e( ex, "status encoding failed." );
}
body_string = json.toString();
request_body = RequestBody.create(
TootApiClient.MEDIA_TYPE_JSON
, body_string
);
}
final Request.Builder request_builder = new Request.Builder()
.post( request_body );
final String digest = Utils.digestSHA256( body_string + account.acct );
if( ! pref.getBoolean( Pref.KEY_DONT_DUPLICATION_CHECK, false ) ){
request_builder.header( "Idempotency-Key", digest );
}
TootApiResult result = client.request( "/api/v1/statuses", request_builder );
if( result != null && result.object != null ){
status = new TootParser( activity, account).status( result.object );
if( status != null ){
Spannable s = status.decoded_content;
MyClickableSpan[] span_list = s.getSpans( 0, s.length(), MyClickableSpan.class );
if( span_list != null ){
ArrayList< String > tag_list = new ArrayList<>();
for( MyClickableSpan span : span_list ){
int start = s.getSpanStart( span );
int end = s.getSpanEnd( span );
String text = s.subSequence( start, end ).toString();
if( text.startsWith( "#" ) ){
tag_list.add( text.substring( 1 ) );
}
}
int count = tag_list.size();
if( count > 0 ){
TagSet.saveList(
System.currentTimeMillis()
, tag_list.toArray( new String[ count ] )
, 0
, count
);
}
}
}
}
return result;
}
@Override public void handleResult( @Nullable TootApiResult result ){
if( result == null ) return; // cancelled.
if( status != null ){
// 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
callback.onPostComplete( target_account, status );
}else{
Utils.showToast( activity, true, result.error );
}
}
} );
}
public interface Callback2 {
void onTextUpdate();
boolean canOpenPopup();
}
private Callback2 callback2;
private MyEditText et;
private PopupAutoCompleteAcct popup;
private View formRoot;
private boolean bMainScreen;
private String instance;
public void setInstance( String instance ){
this.instance = instance == null ? null : instance.toLowerCase();
if( instance != null ){
App1.custom_emoji_lister.get( this.instance, PostHelper.this );
}
if( popup != null && popup.isShowing() ){
proc_text_changed.run();
}
}
public void closeAcctPopup(){
if( popup != null ){
popup.dismiss();
popup = null;
}
}
public void onScrollChanged(){
if( popup != null && popup.isShowing() ){
popup.updatePosition();
}
}
public void onDestroy(){
handler.removeCallbacks( proc_text_changed );
closeAcctPopup();
}
public void attachEditText( View _formRoot, MyEditText _et, boolean bMainScreen, Callback2 _callback2 ){
this.formRoot = _formRoot;
this.et = _et;
this.callback2 = _callback2;
this.bMainScreen = bMainScreen;
et.addTextChangedListener( new TextWatcher() {
@Override
public void beforeTextChanged( CharSequence s, int start, int count, int after ){
}
@Override
public void onTextChanged( CharSequence s, int start, int before, int count ){
handler.removeCallbacks( proc_text_changed );
handler.postDelayed( proc_text_changed, ( popup != null && popup.isShowing() ? 100L : 500L ) );
}
@Override
public void afterTextChanged( Editable s ){
callback2.onTextUpdate();
}
} );
et.setOnSelectionChangeListener( new MyEditText.OnSelectionChangeListener() {
@Override public void onSelectionChanged( int selStart, int selEnd ){
if( selStart != selEnd ){
// 範囲選択されてるならポップアップは閉じる
log.d( "onSelectionChanged: range selected" );
closeAcctPopup();
}
}
} );
// 全然動いてなさそう…
// et.setCustomSelectionActionModeCallback( action_mode_callback );
}
// final ActionMode.Callback action_mode_callback = new ActionMode.Callback() {
// @Override public boolean onCreateActionMode( ActionMode actionMode, Menu menu ){
// actionMode.getMenuInflater().inflate(R.menu.toot_long_tap, menu);
// return true;
// }
// @Override public void onDestroyActionMode( ActionMode actionMode ){
//
// }
// @Override public boolean onPrepareActionMode( ActionMode actionMode, Menu menu ){
// return false;
// }
//
// @Override
// public boolean onActionItemClicked( ActionMode actionMode, MenuItem item ){
// if (item.getItemId() == R.id.action_pick_emoji) {
// actionMode.finish();
// EmojiPicker.open( activity, instance, new EmojiPicker.Callback() {
// @Override public void onPickedEmoji( String name ){
// int end = et.getSelectionEnd();
// String src = et.getText().toString();
// CharSequence svInsert = ":" + name + ":";
// src = src.substring( 0, end ) + svInsert + " " + ( end >= src.length() ? "" : src.substring( end ) );
// et.setText( src );
// et.setSelection( end + svInsert.length() + 1 );
//
// proc_text_changed.run();
// }
// } );
// return true;
// }
//
// return false;
// }
// };
private final Runnable proc_text_changed = new Runnable() {
@Override public void run(){
if( ! callback2.canOpenPopup() ){
closeAcctPopup();
return;
}
int start = et.getSelectionStart();
int end = et.getSelectionEnd();
if( start != end ){
closeAcctPopup();
return;
}
String src = et.getText().toString();
int count_atMark = 0;
int[] pos_atMark = new int[ 2 ];
for( ; ; ){
if( count_atMark >= 2 ) break;
if( start == 0 ) break;
char c = src.charAt( start - 1 );
if( c == '@' ){
-- start;
pos_atMark[ count_atMark++ ] = start;
continue;
}else if( ( '0' <= c && c <= '9' )
|| ( 'A' <= c && c <= 'Z' )
|| ( 'a' <= c && c <= 'z' )
|| c == '_' || c == '-' || c == '.'
){
-- start;
continue;
}
// その他の文字種が出たら探索打ち切り
break;
}
// 登場した@の数
if( count_atMark == 0 ){
// 次はAcctじゃなくてHashtagの補完を試みる
checkTag();
return;
}else if( count_atMark == 1 ){
start = pos_atMark[ 0 ];
}else if( count_atMark == 2 ){
start = pos_atMark[ 1 ];
}
// 最低でも2文字ないと補完しない
if( end - start < 2 ){
closeAcctPopup();
return;
}
int limit = 100;
String s = src.substring( start, end );
ArrayList< CharSequence > acct_list = AcctSet.searchPrefix( s, limit );
log.d( "search for %s, result=%d", s, acct_list.size() );
if( acct_list.isEmpty() ){
closeAcctPopup();
}else{
if( popup == null || ! popup.isShowing() ){
popup = new PopupAutoCompleteAcct( activity, et, formRoot, bMainScreen );
}
popup.setList( et, start, end, acct_list, null, null );
}
}
private void checkTag(){
int end = et.getSelectionEnd();
String src = et.getText().toString();
int last_sharp = src.lastIndexOf( '#', end - 1 );
if( last_sharp == - 1 || end - last_sharp < 2 ){
checkEmoji();
return;
}
String part = src.substring( last_sharp + 1, end );
if( reCharsNotTag.matcher( part ).find() ){
// log.d( "checkTag: character not tag in string %s", part );
checkEmoji();
return;
}
int limit = 100;
String s = src.substring( last_sharp + 1, end );
ArrayList< CharSequence > tag_list = TagSet.searchPrefix( s, limit );
log.d( "search for %s, result=%d", s, tag_list.size() );
if( tag_list.isEmpty() ){
closeAcctPopup();
}else{
if( popup == null || ! popup.isShowing() ){
popup = new PopupAutoCompleteAcct( activity, et, formRoot, bMainScreen );
}
popup.setList( et, last_sharp, end, tag_list, null, null );
}
}
private void checkEmoji(){
int end = et.getSelectionEnd();
String src = et.getText().toString();
int last_colon = src.lastIndexOf( ':', end - 1 );
if( last_colon == - 1 || end - last_colon < 1 ){
closeAcctPopup();
return;
}
String part = src.substring( last_colon + 1, end );
if( reCharsNotEmoji.matcher( part ).find() ){
log.d( "checkEmoji: character not short code in string %s", part );
closeAcctPopup();
return;
}
// : の手前は始端か改行か空白でなければならない
if( last_colon > 0 && ! CharacterGroup.isWhitespace( src.codePointBefore( last_colon ) ) ){
log.d( "checkEmoji: invalid character before shortcode." );
closeAcctPopup();
return;
}
if( part.length() == 0 ){
if( popup == null || ! popup.isShowing() ){
popup = new PopupAutoCompleteAcct( activity, et, formRoot, bMainScreen );
}
popup.setList(
et, last_colon, end
, null
, picker_caption_emoji
, open_picker_emoji
);
return;
}
// 絵文字を部分一致で検索
int limit = 100;
String s = src.substring( last_colon + 1, end ).toLowerCase().replace( '-', '_' );
ArrayList< CharSequence > code_list = EmojiDecoder.searchShortCode( activity, s, limit );
log.d( "checkEmoji: search for %s, result=%d", s, code_list.size() );
// カスタム絵文字を検索
if( ! TextUtils.isEmpty( instance ) ){
CustomEmoji.List custom_list = App1.custom_emoji_lister.get( instance, PostHelper.this );
if( custom_list != null ){
String needle = src.substring( last_colon + 1, end );
for( CustomEmoji item : custom_list ){
if( code_list.size() >= limit ) break;
if( ! item.shortcode.contains( needle ) ) continue;
SpannableStringBuilder sb = new SpannableStringBuilder();
sb.append( ' ' );
sb.setSpan( new NetworkEmojiSpan( item.url ), 0, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE );
sb.append( ' ' );
sb.append( ':' );
sb.append( item.shortcode );
sb.append( ':' );
code_list.add( sb );
}
}
}
if( popup == null || ! popup.isShowing() ){
popup = new PopupAutoCompleteAcct( activity, et, formRoot, bMainScreen );
}
popup.setList( et, last_colon, end, code_list, picker_caption_emoji, open_picker_emoji );
}
};
@Override public void onListLoadComplete( CustomEmoji.List list ){
if( popup != null && popup.isShowing() ){
proc_text_changed.run();
}
}
private final Runnable open_picker_emoji = new Runnable() {
@Override public void run(){
EmojiPicker.open( activity, instance, PostHelper.this );
}
};
@Override public void onPickedEmoji( String name ){
int end = et.getSelectionEnd();
String src = et.getText().toString();
int last_colon = src.lastIndexOf( ':', end - 1 );
if( last_colon == - 1 || end - last_colon < 1 ){
return;
}
CharSequence svInsert = ":" + name + ":";
src = src.substring( 0, last_colon ) + svInsert + " " + ( end >= src.length() ? "" : src.substring( end ) );
et.setText( src );
et.setSelection( last_colon + svInsert.length() + 1 );
proc_text_changed.run();
// キーボードを再度表示する
new Handler( activity.getMainLooper() ).post( new Runnable() {
@Override public void run(){
Utils.showKeyboard( activity, et );
}
} );
}
}