diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt index 57482823..ba3166fb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt @@ -207,11 +207,11 @@ class ActAccountSetting data.handleGetContentResult(contentResolver).firstOrNull()?.let{ addAttachment( requestCode, - it.first, - if( it.second?.isNotEmpty() == true) - it.second + it.uri, + if( it.mimeType?.isNotEmpty() == true) + it.mimeType else - contentResolver.getType(it.first) + contentResolver.getType(it.uri) ) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt index e32dc198..594451d4 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt @@ -918,8 +918,8 @@ class ActAppSetting : AppCompatActivity() override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { if(resultCode == RESULT_OK && data != null && requestCode == REQUEST_CODE_TIMELINE_FONT) { - data.handleGetContentResult(contentResolver).firstOrNull()?.first?.let { uri -> - val file = saveTimelineFont(uri, "TimelineFont") + data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { + val file = saveTimelineFont(it, "TimelineFont") if(file != null) { timeline_font = file.absolutePath saveUIToData() @@ -927,8 +927,8 @@ class ActAppSetting : AppCompatActivity() } } } else if(resultCode == RESULT_OK && data != null && requestCode == REQUEST_CODE_TIMELINE_FONT_BOLD) { - data.handleGetContentResult(contentResolver).firstOrNull()?.first?.let { uri -> - val file = saveTimelineFont(uri, "TimelineFontBold") + data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { + val file = saveTimelineFont(it, "TimelineFontBold") if(file != null) { timeline_font_bold = file.absolutePath saveUIToData() @@ -936,12 +936,8 @@ class ActAppSetting : AppCompatActivity() } } } else if(resultCode == RESULT_OK && data != null && requestCode == REQUEST_CODE_APP_DATA_IMPORT) { - data.handleGetContentResult(contentResolver).firstOrNull()?.first?.let { uri -> - contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - importAppData(false, uri) + data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { + importAppData(false, it) } } super.onActivityResult(requestCode, resultCode, data) diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActColumnCustomize.kt b/app/src/main/java/jp/juggler/subwaytooter/ActColumnCustomize.kt index 2a7caf4a..ae8e448e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActColumnCustomize.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActColumnCustomize.kt @@ -72,7 +72,6 @@ class ActColumnCustomize : AppCompatActivity(), View.OnClickListener, ColorPicke private lateinit var tvSampleAcct : TextView private lateinit var tvSampleContent : TextView - internal var loading_busy : Boolean = false private var last_image_uri : String? = null @@ -108,9 +107,9 @@ class ActColumnCustomize : AppCompatActivity(), View.OnClickListener, ColorPicke } override fun onClick(v : View) { - + val builder : ColorPickerDialog.Builder - + when(v.id) { R.id.btnHeaderBackgroundEdit -> { @@ -227,9 +226,8 @@ class ActColumnCustomize : AppCompatActivity(), View.OnClickListener, ColorPicke override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { if(requestCode == REQUEST_CODE_PICK_BACKGROUND && data != null && resultCode == RESULT_OK) { - data.handleGetContentResult(contentResolver).firstOrNull()?.let { pair -> - updateBackground(pair.first) - } + data.handleGetContentResult(contentResolver).firstOrNull() + ?.uri?.let { updateBackground(it) } } } @@ -428,7 +426,7 @@ class ActColumnCustomize : AppCompatActivity(), View.OnClickListener, ColorPicke loadImage(ivColumnBackground, column.column_bg_image) - tvSampleAcct.setTextColor( column.getAcctColor()) + tvSampleAcct.setTextColor(column.getAcctColor()) tvSampleContent.setTextColor(column.getContentColor()) } finally { diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index 26b906b0..62d14590 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -9,6 +9,7 @@ import android.content.ContentValues import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.database.Cursor import android.graphics.Bitmap import android.net.Uri import android.os.AsyncTask @@ -53,6 +54,8 @@ import org.json.JSONObject import java.io.* import java.lang.ref.WeakReference import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callback { @@ -334,15 +337,12 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba } override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) { + if(requestCode == REQUEST_CODE_ATTACHMENT_OLD && resultCode == Activity.RESULT_OK) { - data?.handleGetContentResult(contentResolver)?.forEach { - addAttachment(it.first, it.second) - } + checkAttachments(data?.handleGetContentResult(contentResolver)) } else if(requestCode == REQUEST_CODE_ATTACHMENT && resultCode == Activity.RESULT_OK) { - data?.handleGetContentResult(contentResolver)?.forEach { - addAttachment(it.first, it.second) - } + checkAttachments(data?.handleGetContentResult(contentResolver)) } else if(requestCode == REQUEST_CODE_CAMERA) { if(resultCode != Activity.RESULT_OK) { @@ -466,19 +466,16 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba this.attachment_list.clear() try { - val array = sv.toJsonArray() - for(i in 0 until array.length()) { + sv.toJsonArray().forEach { + if(it !is JSONObject) return@forEach try { - val a = parseItem( - ::TootAttachment, - ServiceType.MASTODON, - array.optJSONObject(i) - ) - if(a != null) attachment_list.add(PostAttachment(a)) + val a = TootAttachment.decodeJson(it) + attachment_list.add(PostAttachment(a)) } catch(ex : Throwable) { log.trace(ex) } } + attachment_list.sort() } catch(ex : Throwable) { log.trace(ex) } @@ -518,13 +515,13 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba Intent.ACTION_VIEW -> { val uri = sent_intent.data val type = sent_intent.type - if(uri != null) addAttachment(uri, type) + if(uri != null) addAttachment(uri,type) } Intent.ACTION_SEND -> { val uri = sent_intent.getParcelableExtra(Intent.EXTRA_STREAM) val type = sent_intent.type - if(uri != null) addAttachment(uri, type) + if(uri != null) addAttachment(uri,type) } Intent.ACTION_SEND_MULTIPLE -> { @@ -547,18 +544,18 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba if(sv != null && account != null) { try { val reply_status = TootParser(this@ActPost, account).status(sv.toJsonObject()) - + val isQuoterRenote = intent.getBooleanExtra(KEY_QUOTED_RENOTE, false) - + if(reply_status != null) { if(isQuoterRenote) { cbQuoteRenote.isChecked = true - + // 引用リノートはCWやメンションを引き継がない - - }else{ - + + } else { + // CW をリプライ元に合わせる if(reply_status.spoiler_text?.isNotEmpty() == true) { cbContentWarning.isChecked = true @@ -661,6 +658,7 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba this.attachment_list.add(pa) } } + this.attachment_list.sort() } catch(ex : Throwable) { log.trace(ex) } @@ -737,9 +735,8 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba override fun onDestroy() { post_helper.onDestroy() - + attachment_worker?.cancel() super.onDestroy() - } override fun onSaveInstanceState(outState : Bundle?) { @@ -767,10 +764,10 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba if(! attachment_list.isEmpty()) { val array = JSONArray() for(pa in attachment_list) { - if(pa.status == PostAttachment.STATUS_UPLOADED) { - // アップロード完了したものだけ保持する - array.put(pa.attachment?.json) - } + // アップロード完了したものだけ保持する + if(pa.status != PostAttachment.STATUS_UPLOADED) continue + val json = pa.attachment?.encodeJson() ?: continue + array.put(json) } outState.putString(KEY_ATTACHMENT_LIST, array.toString()) } @@ -1237,12 +1234,7 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba llAttachment.visibility = View.GONE } else { llAttachment.visibility = View.VISIBLE - var i = 0 - val ie = ivMedia.size - while(i < ie) { - showAttachment_sub(ivMedia[i], i) - ++ i - } + ivMedia.forEachIndexed { i,v ->showAttachment_sub(v, i) } } } @@ -1600,6 +1592,73 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba return null } + private fun checkAttachments(srcList : ArrayList?) { + srcList ?: return + + fun Cursor.optLong(name : String) : Long? { + val idx = getColumnIndex(name) + val v = if(idx < 0) null else when(getType(idx)) { + Cursor.FIELD_TYPE_INTEGER -> getLong(idx) + Cursor.FIELD_TYPE_FLOAT -> getDouble(idx).toLong() + Cursor.FIELD_TYPE_STRING -> try { + getString(idx).toLong() + } catch(ex : Throwable) { + null + } + else -> null + } + return if(v == 0L) null else v + } + + var countHasTime = 0 + + srcList.forEachIndexed { i, it -> + contentResolver.query(it.uri, null, null, null, null)?.use { cursor -> + if(! cursor.moveToNext()) return@forEachIndexed + for(name in cursor.columnNames.sorted()) { + val idx = cursor.getColumnIndex(name) + when(cursor.getType(idx)) { + Cursor.FIELD_TYPE_NULL -> log.d("[$i]$name=null") + Cursor.FIELD_TYPE_BLOB -> log.d("[$i]$name=(blob)") + Cursor.FIELD_TYPE_STRING -> log.d("[$i]$name=${cursor.getString(idx)}") + Cursor.FIELD_TYPE_INTEGER -> log.d("[$i]$name=(integer)${cursor.getLong(idx)}") + Cursor.FIELD_TYPE_FLOAT -> log.d("[$i]$name=(float)${cursor.getDouble(idx)}") + } + } + val t = cursor.optLong("date_modified") + ?: cursor.optLong("last_modified") + ?: cursor.optLong("date_added") + if(t != null) { + ++ countHasTime + it.time = t + } + } + } + + if( countHasTime == srcList.size ) { + srcList.sortBy { it.time } + } + + srcList.forEach { + addAttachment(it.uri, it.mimeType) + } + + } + + class AttachmentRequest( + val account : SavedAccount, + val pa : PostAttachment, + val uri : Uri, + val mimeType : String, + + val onUploadEnd : () -> Unit + ) + + val attachment_queue = ConcurrentLinkedQueue() + private var attachment_worker: AttachmentWorker? = null + var lastAttachmentAdd : Long = 0L + var lastAttachmentComplete : Long = 0L + @SuppressLint("StaticFieldLeak") private fun addAttachment( uri : Uri, @@ -1631,53 +1690,153 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba app_state.attachment_list = this.attachment_list - val pa = PostAttachment(this) + val pa = PostAttachment( this) attachment_list.add(pa) + attachment_list.sort() showMediaAttachment() - showToast(this, false, R.string.attachment_uploading) + + // アップロード開始トースト(連発しない) + val now = System.currentTimeMillis() + if(now - lastAttachmentAdd >= 5000L) { + showToast(this, false, R.string.attachment_uploading) + } + lastAttachmentAdd = now - TootTaskRunner(this, TootTaskRunner.PROGRESS_NONE).run(account, object : TootTask { - override fun background(client : TootApiClient) : TootApiResult? { - if(mime_type.isEmpty()) { - return TootApiResult("mime_type is empty.") + // マストドンは添付メディアをID順に表示するため + // 画像が複数ある場合は一つずつ処理する必要がある + // 投稿画面ごとに1スレッドだけ作成してバックグラウンド処理を行う + attachment_queue.add(AttachmentRequest(account,pa,uri,mime_type,onUploadEnd)) + val oldWorker = attachment_worker + if( oldWorker == null || !oldWorker.isAlive || oldWorker.isInterrupted ){ + oldWorker?.cancel() + attachment_worker = AttachmentWorker().apply{ start() } + }else{ + oldWorker.notifyEx() + } + } + + inner class AttachmentWorker : WorkerBase(){ + + private val isCancelled = AtomicBoolean(false) + override fun cancel() { + isCancelled.set(true) + notifyEx() + } + + override fun run() { + try { + while(! isCancelled.get()) { + val item = attachment_queue.poll() + if(item == null) { + waitEx(86400) + continue + } + val result = item.upload() + handler.post { + item.handleResult(result) + } + } + }catch(ex:Throwable){ + log.trace(ex) + log.e(ex,"AttachmentWorker") + } + } + + private fun AttachmentRequest.upload():TootApiResult?{ + + if(mimeType.isEmpty()) { + return TootApiResult("mime_type is empty.") + } + + try { + val client = TootApiClient(this@ActPost,callback = object:TootApiCallback{ + override val isApiCancelled : Boolean + get() = isCancelled.get() + }) + + client.account = account + + val opener = createOpener(uri, mimeType) + + val media_size_max = when { + mimeType.startsWith("video") -> { + 1000000 * Math.max(1, Pref.spMovieSizeMax.toInt(pref)) + } + + else -> { + 1000000 * Math.max(1, Pref.spMediaSizeMax.toInt(pref)) + } } - try { - val opener = createOpener(uri, mime_type) - - val media_size_max = when { - mime_type.startsWith("video") -> { - 1000000 * Math.max(1, Pref.spMovieSizeMax.toInt(pref)) - } - - else -> { - 1000000 * Math.max(1, Pref.spMediaSizeMax.toInt(pref)) - } - } - - val content_length = getStreamSize(true, opener.open()) - if(content_length > media_size_max) { - return TootApiResult( - getString( - R.string.file_size_too_big, - media_size_max / 1000000 - ) + val content_length = getStreamSize(true, opener.open()) + if(content_length > media_size_max) { + return TootApiResult( + getString( + R.string.file_size_too_big, + media_size_max / 1000000 ) + ) + } + + + if(account.isMisskey) { + val multipart_builder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + + val apiKey = account.token_info?.parseString(TootApiClient.KEY_API_KEY_MISSKEY) + if(apiKey?.isNotEmpty() == true) { + multipart_builder.addFormDataPart("i", apiKey) } - - if(account.isMisskey) { - val multipart_builder = MultipartBody.Builder() - .setType(MultipartBody.FORM) - - val apiKey = - account.token_info?.parseString(TootApiClient.KEY_API_KEY_MISSKEY) - if(apiKey?.isNotEmpty() == true) { - multipart_builder.addFormDataPart("i", apiKey) + multipart_builder.addFormDataPart( + "file", getDocumentName(contentResolver, uri), object : RequestBody() { + override fun contentType() : MediaType? { + return MediaType.parse(opener.mimeType) + } + + @Throws(IOException::class) + override fun contentLength() : Long { + return content_length + } + + @Throws(IOException::class) + override fun writeTo(sink : BufferedSink) { + opener.open().use { inData -> + val tmp = ByteArray(4096) + while(true) { + val r = inData.read(tmp, 0, tmp.size) + if(r <= 0) break + sink.write(tmp, 0, r) + } + } + } } - - multipart_builder.addFormDataPart( - "file", getDocumentName(contentResolver, uri), object : RequestBody() { + ) + + val request_builder = Request.Builder().post(multipart_builder.build()) + + val result = client.request("/api/drive/files/create", request_builder) + + opener.deleteTempFile() + onUploadEnd() + + val jsonObject = result?.jsonObject + if(jsonObject != null) { + val a = parseItem(::TootAttachment, ServiceType.MISSKEY, jsonObject) + if(a == null) { + result.error = "TootAttachment.parse failed" + } else { + pa.attachment = a + } + } + return result + } else { + val multipart_body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + getDocumentName(contentResolver, uri), + object : RequestBody() { override fun contentType() : MediaType? { return MediaType.parse(opener.mimeType) } @@ -1700,94 +1859,47 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba } } ) - - val request_builder = Request.Builder().post(multipart_builder.build()) - - val result = client.request("/api/drive/files/create", request_builder) - - opener.deleteTempFile() - onUploadEnd() - - val jsonObject = result?.jsonObject - if(jsonObject != null) { - val a = parseItem(::TootAttachment, ServiceType.MISSKEY, jsonObject) - if(a == null) { - result.error = "TootAttachment.parse failed" - } else { - pa.attachment = a - } - } - return result - } else { - val multipart_body = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - "file", - getDocumentName(contentResolver, uri), - object : RequestBody() { - override fun contentType() : MediaType? { - return MediaType.parse(opener.mimeType) - } - - @Throws(IOException::class) - override fun contentLength() : Long { - return content_length - } - - @Throws(IOException::class) - override fun writeTo(sink : BufferedSink) { - opener.open().use { inData -> - val tmp = ByteArray(4096) - while(true) { - val r = inData.read(tmp, 0, tmp.size) - if(r <= 0) break - sink.write(tmp, 0, r) - } - } - } - } - ) - .build() - - val request_builder = Request.Builder() - .post(multipart_body) - - val result = client.request("/api/v1/media", request_builder) - - opener.deleteTempFile() - onUploadEnd() - - val jsonObject = result?.jsonObject - if(jsonObject != null) { - val a = parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject) - if(a == null) { - result.error = "TootAttachment.parse failed" - } else { - pa.attachment = a - } - } - return result - } + .build() - } catch(ex : Throwable) { - return TootApiResult(ex.withCaption("read failed.")) + val request_builder = Request.Builder() + .post(multipart_body) + + val result = client.request("/api/v1/media", request_builder) + + opener.deleteTempFile() + onUploadEnd() + + val jsonObject = result?.jsonObject + if(jsonObject != null) { + val a = parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject) + if(a == null) { + result.error = "TootAttachment.parse failed" + } else { + pa.attachment = a + } + } + return result } + } catch(ex : Throwable) { + return TootApiResult(ex.withCaption("read failed.")) } - override fun handleResult(result : TootApiResult?) { - if(pa.attachment == null) { - pa.status = PostAttachment.STATUS_UPLOAD_FAILED - if(result != null) { - showToast(this@ActPost, true, result.error) - } - } else { - pa.status = PostAttachment.STATUS_UPLOADED + } + + fun AttachmentRequest.handleResult(result : TootApiResult?) { + + if(pa.attachment == null) { + pa.status = PostAttachment.STATUS_UPLOAD_FAILED + if(result != null) { + showToast(this@ActPost, true, result.error) } - // 投稿中に画面回転があった場合、新しい画面のコールバックを呼び出す必要がある - pa.callback?.onPostAttachmentComplete(pa) + } else { + pa.status = PostAttachment.STATUS_UPLOADED } - }) + // 投稿中に画面回転があった場合、新しい画面のコールバックを呼び出す必要がある + pa.callback?.onPostAttachmentComplete(pa) + } } // 添付メディア投稿が完了したら呼ばれる @@ -1808,7 +1920,12 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba val a = pa.attachment if(a != null) { // アップロード完了 - showToast(this@ActPost, false, R.string.attachment_uploaded) + + val now = System.currentTimeMillis() + if(now - lastAttachmentComplete >= 5000L) { + showToast(this@ActPost, false, R.string.attachment_uploaded) + } + lastAttachmentComplete = now if(Pref.bpAppendAttachmentUrlToContent(pref)) { // 投稿欄の末尾に追記する @@ -2093,8 +2210,8 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba try { val tmp_attachment_list = JSONArray() for(pa in attachment_list) { - val a = pa.attachment - if(a != null) tmp_attachment_list.put(a.json) + val json = pa.attachment?.encodeJson() + if(json != null) tmp_attachment_list.put(json) } val json = JSONObject() @@ -2148,34 +2265,28 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba var content = draft.parseString(DRAFT_CONTENT) ?: "" val account_db_id = draft.parseLong(DRAFT_ACCOUNT_DB_ID) ?: - 1L - var tmp_attachment_list = draft.optJSONArray(DRAFT_ATTACHMENT_LIST) + val tmp_attachment_list = draft.optJSONArray(DRAFT_ATTACHMENT_LIST)?.toObjectList() val account = SavedAccount.loadAccount(this@ActPost, account_db_id) if(account == null) { list_warning.add(getString(R.string.account_in_draft_is_lost)) try { - var i = 0 - val ie = tmp_attachment_list.length() - while(i < ie) { - val ta = - parseItem( - ::TootAttachment, - ServiceType.MASTODON, - tmp_attachment_list.optJSONObject(i) - ) - val text_url = ta?.text_url - if(text_url?.isNotEmpty() == true) { - content = content.replace(text_url, "") + if(tmp_attachment_list != null) { + // 本文からURLを除去する + tmp_attachment_list.forEach { + val text_url = TootAttachment.decodeJson(it).text_url + if(text_url?.isNotEmpty() == true) { + content = content.replace(text_url, "") + } } - ++ i + tmp_attachment_list.clear() + draft.put(DRAFT_ATTACHMENT_LIST, tmp_attachment_list.toJsonArray()) + draft.put(DRAFT_CONTENT, content) + draft.remove(DRAFT_REPLY_ID) + draft.remove(DRAFT_REPLY_TEXT) + draft.remove(DRAFT_REPLY_IMAGE) + draft.remove(DRAFT_REPLY_URL) } - tmp_attachment_list = JSONArray() - draft.put(DRAFT_ATTACHMENT_LIST, tmp_attachment_list) - draft.put(DRAFT_CONTENT, content) - draft.remove(DRAFT_REPLY_ID) - draft.remove(DRAFT_REPLY_TEXT) - draft.remove(DRAFT_REPLY_IMAGE) - draft.remove(DRAFT_REPLY_URL) } catch(ignored : JSONException) { } @@ -2208,30 +2319,27 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba } } try { - var isSomeAttachmentRemoved = false - for(i in tmp_attachment_list.length() - 1 downTo 0) { - if(isCancelled) return null - val ta = parseItem( - ::TootAttachment, - ServiceType.MASTODON, - tmp_attachment_list.optJSONObject(i) - ) - if(ta == null) { + if(tmp_attachment_list != null) { + // 添付メディアの存在確認 + var isSomeAttachmentRemoved = false + val it = tmp_attachment_list.iterator() + while(it.hasNext()) { + if(isCancelled) return null + val ta = TootAttachment.decodeJson(it.next()) + if(check_exist(ta.url)) continue + it.remove() isSomeAttachmentRemoved = true - tmp_attachment_list.remove(i) - } else if(! check_exist(ta.url)) { - isSomeAttachmentRemoved = true - tmp_attachment_list.remove(i) + // 本文からURLを除去する val text_url = ta.text_url if(text_url?.isNotEmpty() == true) { content = content.replace(text_url, "") } } - } - if(isSomeAttachmentRemoved) { - list_warning.add(getString(R.string.attachment_in_draft_is_lost)) - draft.put(DRAFT_ATTACHMENT_LIST, tmp_attachment_list) - draft.put(DRAFT_CONTENT, content) + if(isSomeAttachmentRemoved) { + list_warning.add(getString(R.string.attachment_in_draft_is_lost)) + draft.put(DRAFT_ATTACHMENT_LIST, tmp_attachment_list.toJsonArray()) + draft.put(DRAFT_CONTENT, content) + } } } catch(ex : JSONException) { log.trace(ex) @@ -2292,21 +2400,14 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba if(tmp_attachment_list.length() > 0) { attachment_list.clear() - var i = 0 - val ie = tmp_attachment_list.length() - while(i < ie) { - val ta = parseItem( - ::TootAttachment, - ServiceType.MASTODON, - tmp_attachment_list.optJSONObject(i) - ) - if(ta != null) { - val pa = PostAttachment(ta) - attachment_list.add(pa) - } - ++ i + tmp_attachment_list.forEach { + if(it !is JSONObject) return@forEach + val pa = PostAttachment(TootAttachment.decodeJson(it)) + attachment_list.add(pa) } + attachment_list.sort() } + if(reply_id != null) { in_reply_to_id = reply_id in_reply_to_text = reply_text @@ -2314,7 +2415,6 @@ class ActPost : AppCompatActivity(), View.OnClickListener, PostAttachment.Callba in_reply_to_url = reply_url } - updateContentWarning() showMediaAttachment() showVisibility() diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityUtil.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityUtil.kt index f1dc6036..14f4a592 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityUtil.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityUtil.kt @@ -205,13 +205,12 @@ inline fun parseListOrNull( fun ArrayList.encodeJson() : JSONArray { val a = JSONArray() - for(ta in this) { - if(ta is TootAttachment) { - try { - a.put(ta.json) - } catch(ex : JSONException) { - EntityUtil.log.e(ex, "encode failed.") - } + forEach { ta-> + if(ta !is TootAttachment) return@forEach + try { + a.put(ta.encodeJson()) + } catch(ex : JSONException) { + EntityUtil.log.e(ex, "encode failed.") } } return a diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt index f8009e20..57dff187 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt @@ -27,7 +27,7 @@ open class TootAccount(parser : TootParser, src : JSONObject) { // host, user ,(instance) internal val reAccountUrl : Pattern = - Pattern.compile("\\Ahttps://([A-Za-z0-9._-]+)/@([A-Za-z0-9_]+)(?:@([A-Za-z0-9._-]+))?(?:\\z|[?#])") + Pattern.compile("""\Ahttps://([A-Za-z0-9._-]+)/@([A-Za-z0-9_][A-Za-z0-9_-]+)(?:@([A-Za-z0-9._-]+))?(?:\z|[?#])""") fun getAcctFromUrl(url : String) : String? { val m = reAccountUrl.matcher(url) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt index 408aef24..1ffc6564 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt @@ -7,10 +7,11 @@ import org.json.JSONObject import jp.juggler.subwaytooter.Pref import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.util.clipRange +import jp.juggler.subwaytooter.util.jsonObject import jp.juggler.subwaytooter.util.parseLong import jp.juggler.subwaytooter.util.parseString -class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentLike { +class TootAttachment : TootAttachmentLike { companion object { private fun parseFocusValue(parent : JSONObject?, key : String) : Float { @@ -19,13 +20,30 @@ class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentL if(dv.isFinite()) return clipRange(- 1f, 1f, dv.toFloat()) } return 0f - } + + // 下書きからの復元などに使うパラメータ + // 後方互換性的な理由で概ねマストドンに合わせている + private const val KEY_IS_STRING_ID = "isStringId" + private const val KEY_ID = "id" + private const val KEY_TYPE = "type" + private const val KEY_URL = "url" + private const val KEY_REMOTE_URL = "remote_url" + private const val KEY_PREVIEW_URL = "preview_url" + private const val KEY_TEXT_URL = "text_url" + private const val KEY_DESCRIPTION = "description" + private const val KEY_IS_SENSITIVE = "isSensitive" + private const val KEY_META = "meta" + private const val KEY_FOCUS = "focus" + private const val KEY_X = "x" + private const val KEY_Y = "y" + private const val KEY_TIME_START_UPLOAD = "KEY_TIME_START_UPLOAD" + var timeSeed : Long = 0L + + fun decodeJson(src : JSONObject) = TootAttachment(src, decode = true) } - constructor(parser:TootParser,src:JSONObject):this(parser.serviceType,src) - - val json : JSONObject + constructor(parser : TootParser, src : JSONObject) : this(parser.serviceType, src) // ID of the attachment val id : EntityId @@ -52,10 +70,12 @@ class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentL override val focusY : Float // 内部フラグ: 再編集で引き継いだ添付メディアなら真 - var redraft :Boolean = false + var redraft : Boolean = false // MisskeyはメディアごとにNSFWフラグがある - val isSensitive :Boolean + val isSensitive : Boolean + + var timeStartUpload : Long = 0L /////////////////////////////// @@ -64,39 +84,40 @@ class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentL else -> false } - init { - json = src + constructor(serviceType : ServiceType, src : JSONObject) { when(serviceType) { ServiceType.MISSKEY -> { - id = EntityId.mayDefault( src.parseString("id")) + id = EntityId.mayDefault(src.parseString("id")) - val mimeType = src.parseString("type") ?: "?" - this.type = when{ - mimeType.startsWith("image/") -> TootAttachmentLike.TYPE_IMAGE - mimeType.startsWith("video/") -> TootAttachmentLike.TYPE_VIDEO - mimeType.startsWith("audio/") -> TootAttachmentLike.TYPE_VIDEO - else-> TootAttachmentLike.TYPE_UNKNOWN + val mimeType = src.parseString("type") ?: "?" + this.type = when { + mimeType.startsWith("image/") -> TootAttachmentLike.TYPE_IMAGE + mimeType.startsWith("video/") -> TootAttachmentLike.TYPE_VIDEO + mimeType.startsWith("audio/") -> TootAttachmentLike.TYPE_VIDEO + else -> TootAttachmentLike.TYPE_UNKNOWN } url = src.parseString("url") preview_url = src.parseString("thumbnailUrl") remote_url = url text_url = url - + description = arrayOf( src.parseString("name"), src.parseString("comment") ) .filterNotNull() .joinToString(" / ") - + focusX = 0f focusY = 0f - isSensitive = src.optBoolean("isSensitive",false) + isSensitive = src.optBoolean("isSensitive", false) + } - else->{ - id= EntityId.mayDefault(src.parseLong("id") ) + + else -> { + id = EntityId.mayDefault(src.parseLong("id")) type = src.parseString("type") url = src.parseString("url") remote_url = src.parseString("remote_url") @@ -110,6 +131,10 @@ class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentL focusY = parseFocusValue(focus, "y") } } + + // internal use + // muse be > 0 + this.timeStartUpload = src.parseLong(KEY_TIME_START_UPLOAD) ?: ++ timeSeed } override val urlForThumbnail : String? @@ -139,6 +164,52 @@ class TootAttachment(serviceType:ServiceType,src : JSONObject) : TootAttachmentL } return result } + + fun encodeJson() = jsonObject { + put(KEY_IS_STRING_ID, id is EntityIdString) + put(KEY_ID, id.toString()) + put(KEY_TYPE, type) + put(KEY_URL, url) + put(KEY_REMOTE_URL, remote_url) + put(KEY_PREVIEW_URL, preview_url) + put(KEY_TEXT_URL, text_url) + put(KEY_DESCRIPTION, description) + put(KEY_IS_SENSITIVE, isSensitive) + put(KEY_TIME_START_UPLOAD, timeStartUpload) + + if(focusX != 0f || focusY != 0f) { + put(KEY_META, jsonObject { + put(KEY_FOCUS, jsonObject { + put(KEY_X, focusX) + put(KEY_Y, focusY) + }) + }) + } + + } + + constructor(src : JSONObject, decode : Boolean) { + + id = if( src.optBoolean(KEY_IS_STRING_ID) ) { + EntityId.mayDefault(src.parseString(KEY_ID)) + } else { + EntityId.mayDefault(src.parseLong(KEY_ID)) + } + + type = src.parseString(KEY_TYPE) + url = src.parseString(KEY_URL) + remote_url = src.parseString(KEY_REMOTE_URL) + preview_url = src.parseString(KEY_PREVIEW_URL) + text_url = src.parseString(KEY_TEXT_URL) + description = src.parseString(KEY_DESCRIPTION) + isSensitive = src.optBoolean(KEY_IS_SENSITIVE) + + val focus = src.optJSONObject(KEY_META)?.optJSONObject(KEY_FOCUS) + focusX = parseFocusValue(focus, KEY_X) + focusY = parseFocusValue(focus, KEY_Y) + + timeStartUpload = src.parseLong(KEY_TIME_START_UPLOAD) ?: ++ timeSeed + } } // v1.3 から 添付ファイルの画像のピクセルサイズが取得できるようになった diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/BitmapUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/BitmapUtils.kt index 3dd809ac..d0c34e4d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/BitmapUtils.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/BitmapUtils.kt @@ -56,11 +56,11 @@ fun createResizedBitmap( } val resize_to = Math.min(size, resizeToArg) - + // inSampleSizeを計算 var bits = 0 var x = size - while(x > resize_to * 2) { + while(resize_to > 0 && x > resize_to * 2) { ++ bits x = x shr 1 } @@ -121,7 +121,6 @@ fun createResizedBitmap( 6 -> { tmp = dst_width - dst_width = dst_height dst_height = tmp matrix.postRotate(90f) diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PostAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PostAttachment.kt index 1f6511e3..16fc5596 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PostAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PostAttachment.kt @@ -2,8 +2,8 @@ package jp.juggler.subwaytooter.util import jp.juggler.subwaytooter.api.entity.TootAttachment -class PostAttachment { - +class PostAttachment : Comparable{ + companion object { const val STATUS_UPLOADING = 1 const val STATUS_UPLOADED = 2 @@ -16,10 +16,9 @@ class PostAttachment { var status : Int var attachment : TootAttachment? = null + var callback : Callback? = null - - - + constructor(callback : Callback) { this.status = STATUS_UPLOADING this.callback = callback @@ -30,4 +29,13 @@ class PostAttachment { this.attachment = a } + override fun compareTo(other : PostAttachment) : Int { + val ta = this.attachment + val tb = other.attachment + return if(ta != null ){ + if( tb == null) 1 else ta.id.compareTo(tb.id) + }else{ + if( tb == null) 0 else -1 + } + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt index 273c6fd9..09326792 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt @@ -444,6 +444,8 @@ class PostHelper( val a = pa.attachment ?: continue if(a.redraft && ! instance.versionGE(TootInstance.VERSION_2_4_1)) continue array.put(a.id) + + log.d("media_ids id=${a.id} time=${pa.timeStartUpload}") } json.put("media_ids", array) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt index ced44905..392e87ce 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/Utils.kt @@ -349,14 +349,14 @@ fun ByteArray.digestSHA256() : ByteArray { } fun ByteArray.startWith( - key:ByteArray, - thisOffset :Int =0, - keyOffset:Int=0, - length:Int = key.size-keyOffset -):Boolean{ - if( this.size -thisOffset >= length && key.size -keyOffset >=length ){ - for( i in 0 until length ){ - if( this[i+thisOffset] != key[i+keyOffset]) return false + key : ByteArray, + thisOffset : Int = 0, + keyOffset : Int = 0, + length : Int = key.size - keyOffset +) : Boolean { + if(this.size - thisOffset >= length && key.size - keyOffset >= length) { + for(i in 0 until length) { + if(this[i + thisOffset] != key[i + keyOffset]) return false } return true } @@ -364,24 +364,23 @@ fun ByteArray.startWith( } // 各要素の下位8ビットを使ってバイト配列を作る -fun IntArray.toByteArray():ByteArray{ +fun IntArray.toByteArray() : ByteArray { val dst = ByteArray(this.size) - for(i in 0 until this.size){ + for(i in 0 until this.size) { dst[i] = this[i].toByte() } return dst } // 各要素の下位8ビットを使ってバイト配列を作る -fun CharArray.toByteArray():ByteArray{ +fun CharArray.toByteArray() : ByteArray { val dst = ByteArray(this.size) - for(i in 0 until this.size){ + for(i in 0 until this.size) { dst[i] = this[i].toByte() } return dst } - //// MD5ハッシュの作成 //@Suppress("unused") //fun String.digestMD5() : String { @@ -398,9 +397,9 @@ fun String.digestSHA256Base64Url() : String { return this.encodeUTF8().digestSHA256().encodeBase64Url() } -fun String.toUri():Uri = Uri.parse(this) +fun String.toUri() : Uri = Uri.parse(this) -fun String.unescapeUri():String = Uri.decode(this) +fun String.unescapeUri() : String = Uri.decode(this) //////////////////////////////////////////////////////////////////// // CharSequence @@ -485,10 +484,10 @@ fun String?.optInt() : Int? { } } -fun String?.filterNotEmpty() :String? = when{ - this==null -> null +fun String?.filterNotEmpty() : String? = when { + this == null -> null this.isEmpty() -> null - else->this + else -> this } //fun String.ellipsize(max : Int) = if(this.length > max) this.substring(0, max - 1) + "…" else this @@ -675,6 +674,24 @@ inline fun JSONArray.downForEachIndexed(block : (i : Int, v : Any?) -> Unit) { } } +fun JSONArray.toAnyList() : ArrayList { + val dst_list = ArrayList(length()) + forEach { if(it != null) dst_list.add(it) } + return dst_list +} + +fun JSONArray.toObjectList() : ArrayList { + val dst_list = ArrayList(length()) + forEach { if(it is JSONObject) dst_list.add(it) } + return dst_list +} + +fun List.toJsonArray() : JSONArray { + val dst_list = JSONArray() + forEach { dst_list.put(it) } + return dst_list +} + fun JSONArray.toStringArrayList() : ArrayList { val dst_list = ArrayList(length()) forEach { o -> @@ -706,6 +723,7 @@ fun JSONObject.parseFloatArrayList(name : String) : ArrayList? { } return null } + fun String.toJsonObject() = JSONObject(this) fun String.toJsonArray() = JSONArray(this) @@ -782,6 +800,12 @@ fun JSONObject.parseInt(key : String) : Int? { fun JSONObject.toPostRequestBuilder() : Request.Builder = Request.Builder().post(RequestBody.create(TootApiClient.MEDIA_TYPE_JSON, this.toString())) +fun jsonObject(initializer : JSONObject.() -> Unit) : JSONObject { + val dst = JSONObject() + dst.initializer() + return dst +} + //////////////////////////////////////////////////////////////////// // Bundle @@ -881,20 +905,19 @@ fun vg(v : View, visible : Boolean) { v.visibility = if(visible) View.VISIBLE else View.GONE } -fun Float.abs() :Float = Math.abs(this) +fun Float.abs() : Float = Math.abs(this) //////////////////////////////////////////////////////////////////// // context -fun Context.loadRawResource( resId:Int):ByteArray{ - resources.openRawResource(resId).use{ inStream-> - val bao = ByteArrayOutputStream( inStream.available() ) - IOUtils.copy(inStream,bao) +fun Context.loadRawResource(resId : Int) : ByteArray { + resources.openRawResource(resId).use { inStream -> + val bao = ByteArrayOutputStream(inStream.available()) + IOUtils.copy(inStream, bao) return bao.toByteArray() } } - //////////////////////////////////////////////////////////////////// // file @@ -936,17 +959,16 @@ fun showToast(context : Context, ex : Throwable, string_id : Int, vararg args : Utils.showToastImpl(context, true, ex.withCaption(context.resources, string_id, *args)) } - -fun Cursor.getInt(key:String) = +fun Cursor.getInt(key : String) = getInt(getColumnIndex(key)) -fun Cursor.getIntOrNull(idx:Int) = +fun Cursor.getIntOrNull(idx : Int) = if(isNull(idx)) null else getInt(idx) -fun Cursor.getIntOrNull(key:String) = +fun Cursor.getIntOrNull(key : String) = getIntOrNull(getColumnIndex(key)) -fun Cursor.getLong(key:String) = +fun Cursor.getLong(key : String) = getLong(getColumnIndex(key)) //fun Cursor.getLongOrNull(idx:Int) = @@ -955,18 +977,16 @@ fun Cursor.getLong(key:String) = //fun Cursor.getLongOrNull(key:String) = // getLongOrNull(getColumnIndex(key)) -fun Cursor.getString(key:String) :String = +fun Cursor.getString(key : String) : String = getString(getColumnIndex(key)) -fun Cursor.getStringOrNull(keyIdx:Int) = +fun Cursor.getStringOrNull(keyIdx : Int) = if(isNull(keyIdx)) null else getString(keyIdx) -fun Cursor.getStringOrNull(key:String) = +fun Cursor.getStringOrNull(key : String) = getStringOrNull(getColumnIndex(key)) - - -fun getDocumentName(contentResolver:ContentResolver,uri : Uri) : String { +fun getDocumentName(contentResolver : ContentResolver, uri : Uri) : String { val errorName = "no_name" return contentResolver.query(uri, null, null, null, null, null) ?.use { cursor -> @@ -995,7 +1015,7 @@ fun getStreamSize(bClose : Boolean, inStream : InputStream) : Long { } } -fun intentOpenDocument(mimeType : String):Intent{ +fun intentOpenDocument(mimeType : String) : Intent { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = mimeType // "image/*" @@ -1018,63 +1038,69 @@ fun intentGetContent( // EXTRA_MIME_TYPES は API 19以降。ACTION_GET_CONTENT でも ACTION_OPEN_DOCUMENT でも指定できる intent.putExtra("android.intent.extra.MIME_TYPES", mimeTypes) - intent.type =when { - mimeTypes.size == 1 -> mimeTypes[0] - + intent.type = when { + mimeTypes.size == 1 -> mimeTypes[0] + // On Android 6.0 and above using "video/* image/" or "image/ video/*" type doesn't work // it only recognizes the first filter you specify. Build.VERSION.SDK_INT >= 23 -> "*/*" - + else -> mimeTypes.joinToString(" ") } return Intent.createChooser(intent, caption) } +data class GetContentResultEntry( + val uri:Uri, + val mimeType:String? =null, + var time : Long? =null +) + // returns list of pair of uri and mime-type. -fun Intent.handleGetContentResult(contentResolver : ContentResolver) : ArrayList> { - val urlList = ArrayList>() +fun Intent.handleGetContentResult(contentResolver : ContentResolver) : ArrayList { + val urlList = ArrayList() // 単一選択 this.data?.let { - urlList.add(Pair(it, this.type)) + urlList.add(GetContentResultEntry(it, this.type)) } // 複数選択 val cd = this.clipData if(cd != null) { for(i in 0 until cd.itemCount) { cd.getItemAt(i)?.uri?.let { uri -> - if(null == urlList.find { it.first == uri }) { - urlList.add(Pair(uri, null as String?)) + if(null == urlList.find { it.uri == uri }) { + urlList.add(GetContentResultEntry(uri )) } } } } urlList.forEach { - try{ + try { contentResolver.takePersistableUriPermission( - it.first, + it.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) - }catch(_:Throwable){ + } catch(_ : Throwable) { } } return urlList } -fun Matcher.groupOrNull( g:Int):String? = - if( groupCount() >= g ){ +fun Matcher.groupOrNull(g : Int) : String? = + if(groupCount() >= g) { group(g) - }else { + } else { null } // colorARGB.applyAlphaMultiplier(0.5f) でalpha値が半分になったARGB値を得る -fun Int.applyAlphaMultiplier(alphaMultiplier: Float? = null):Int{ - return if( alphaMultiplier ==null ){ +fun Int.applyAlphaMultiplier(alphaMultiplier : Float? = null) : Int { + return if(alphaMultiplier == null) { this - }else{ + } else { val rgb = (this and 0xffffff) - val alpha = clipRange(0,255,((this ushr 24).toFloat() * alphaMultiplier +0.5f ).toInt()) + val alpha = clipRange(0, 255, ((this ushr 24).toFloat() * alphaMultiplier + 0.5f).toInt()) return rgb or (alpha shl 24) } } \ No newline at end of file