From c614270e65a47d8bff46dbd7f45421caa2d4f50c Mon Sep 17 00:00:00 2001 From: aforge Date: Fri, 17 Feb 2023 10:41:21 -0800 Subject: [PATCH 01/12] Fix loudspeaker in video calls. --- app/src/main/java/co/tinode/tindroid/CallFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/co/tinode/tindroid/CallFragment.java b/app/src/main/java/co/tinode/tindroid/CallFragment.java index 316e439e..d4ded5bc 100644 --- a/app/src/main/java/co/tinode/tindroid/CallFragment.java +++ b/app/src/main/java/co/tinode/tindroid/CallFragment.java @@ -167,7 +167,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, mLayout = v.findViewById(R.id.callMainLayout); AudioManager audioManager = (AudioManager) inflater.getContext().getSystemService(Context.AUDIO_SERVICE); - audioManager.setMode(AudioManager.MODE_IN_CALL); + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); audioManager.setSpeakerphoneOn(true); // Button click handlers: speakerphone on/off, mute/unmute, video/audio-only, hang up. @@ -254,6 +254,7 @@ public void onDestroyView() { if (ctx != null) { AudioManager audioManager = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE); if (audioManager != null) { + audioManager.setMode(AudioManager.MODE_NORMAL); audioManager.setMicrophoneMute(false); audioManager.setSpeakerphoneOn(false); } From aba95f962782a429f302ca94ab964199cc8fcab7 Mon Sep 17 00:00:00 2001 From: aforge Date: Fri, 17 Feb 2023 14:16:44 -0800 Subject: [PATCH 02/12] Use correct effective seq in call event listener. --- .../main/java/co/tinode/tindroid/Cache.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/Cache.java b/app/src/main/java/co/tinode/tindroid/Cache.java index 7e9ff707..34636e65 100644 --- a/app/src/main/java/co/tinode/tindroid/Cache.java +++ b/app/src/main/java/co/tinode/tindroid/Cache.java @@ -72,15 +72,16 @@ public void onDataMessage(MsgServerData data) { } int effectiveSeq = UiUtils.parseSeqReference(data.getStringHeader("replace")); - if (effectiveSeq > 0) { - // Check if we have a later version of the message (which means the call - // has been not yet either accepted or finished). - Storage.Message msg = topic.getMessage(effectiveSeq); - if (msg != null) { - webrtc = msg.getStringHeader("webrtc"); - if (webrtc != null && MsgServerData.parseWebRTC(webrtc) != callState) { - return; - } + if (effectiveSeq <= 0) { + effectiveSeq = data.seq; + } + // Check if we have a later version of the message (which means the call + // has been not yet either accepted or finished). + Storage.Message msg = topic.getMessage(effectiveSeq); + if (msg != null) { + webrtc = msg.getStringHeader("webrtc"); + if (webrtc != null && MsgServerData.parseWebRTC(webrtc) != callState) { + return; } } From a9e88710f4eac97d4e7ea9847d901d002aa87594 Mon Sep 17 00:00:00 2001 From: aforge Date: Fri, 17 Feb 2023 18:33:52 -0800 Subject: [PATCH 03/12] Start audio-only calls with the loudspeaker off. --- app/src/main/java/co/tinode/tindroid/CallFragment.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/CallFragment.java b/app/src/main/java/co/tinode/tindroid/CallFragment.java index d4ded5bc..8095523b 100644 --- a/app/src/main/java/co/tinode/tindroid/CallFragment.java +++ b/app/src/main/java/co/tinode/tindroid/CallFragment.java @@ -166,10 +166,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, mLayout = v.findViewById(R.id.callMainLayout); - AudioManager audioManager = (AudioManager) inflater.getContext().getSystemService(Context.AUDIO_SERVICE); - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - audioManager.setSpeakerphoneOn(true); - // Button click handlers: speakerphone on/off, mute/unmute, video/audio-only, hang up. mToggleSpeakerphoneBtn.setOnClickListener(v0 -> toggleSpeakerphone((FloatingActionButton) v0)); @@ -206,6 +202,10 @@ public void onViewCreated(@NonNull View view, Bundle savedInstance) { } mAudioOnly = args.getBoolean(Const.INTENT_EXTRA_CALL_AUDIO_ONLY); + AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setSpeakerphoneOn(!mAudioOnly); + mToggleSpeakerphoneBtn.setImageResource(mAudioOnly ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); if (!mTopic.isAttached()) { mTopic.setListener(new Topic.Listener() { From 74df46e1bc23e32a8c1c04bb5c3dcab513f6746f Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 11:07:32 -0800 Subject: [PATCH 04/12] fix crash on download --- app/src/main/res/xml/provider_paths.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index d19e2bcc..6d0967db 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file From c22b035a4293963f359bf79ae09fc104761f7a61 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 13:13:36 -0800 Subject: [PATCH 05/12] fix preview of image attachments (use full image) --- .../co/tinode/tindroid/AttachmentHandler.java | 37 ++--- .../co/tinode/tindroid/ImageViewFragment.java | 139 +++++++++--------- 2 files changed, 89 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java b/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java index fe21d23f..9e61789e 100644 --- a/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java +++ b/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java @@ -100,7 +100,7 @@ public class AttachmentHandler extends Worker { private static final String TAG = "AttachmentHandler"; - private final List mUploaders = Collections.synchronizedList(new ArrayList<>()); + private LargeFileHelper mUploader = null; public AttachmentHandler(@NonNull Context context, @NonNull WorkerParameters params) { super(context, params); @@ -452,10 +452,8 @@ public ListenableWorker.Result doWork() { @Override public void onStopped() { - synchronized (mUploaders) { - for (LargeFileHelper lfh : mUploaders) { - lfh.cancel(); - } + if (mUploader != null) { + mUploader.cancel(); } super.onStopped(); @@ -608,32 +606,25 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f .putLong(ARG_FILE_SIZE, uploadDetails.fileSize).build()); // Upload results. - //noinspection unchecked + // noinspection unchecked PromisedReply[] uploadResults = (PromisedReply[]) new PromisedReply[2]; // Upload large media. - LargeFileHelper uploader = Cache.getTinode().getLargeFileHelper(); - mUploaders.add(uploader); - uploadResults[0] = uploader.uploadAsync(is, uploadDetails.fileName, + mUploader = Cache.getTinode().getLargeFileHelper(); + uploadResults[0] = mUploader.uploadAsync(is, uploadDetails.fileName, uploadDetails.mimeType, uploadDetails.fileSize, topicName, (progress, size) -> setProgressAsync(new Data.Builder() .putAll(result.build()) .putLong(ARG_PROGRESS, progress) .putLong(ARG_FILE_SIZE, size) .build())); - mUploaders.remove(uploader); - if (uploader.isCanceled()) { - throw new CancellationException(); - } // Optionally upload video poster. if (uploadDetails.previewRef != null) { - LargeFileHelper posterUploader = Cache.getTinode().getLargeFileHelper(); - mUploaders.add(posterUploader); - uploadResults[1] = uploader.uploadAsync(is, uploadDetails.fileName, uploadDetails.mimeType, - uploadDetails.fileSize, topicName, null); - mUploaders.remove(posterUploader); - // Don't care if it's cancelled. + uploadResults[1] = mUploader.uploadAsync(new ByteArrayInputStream(uploadDetails.previewBits), + "poster", uploadDetails.previewMime, uploadDetails.previewSize, + topicName, null); + // ByteArrayInputStream:close() is a noop. No need to call close(). } else { uploadResults[1] = null; } @@ -648,6 +639,8 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f throw new CancellationException(); } + mUploader = null; + success = msgs[0] != null && msgs[0].ctrl != null && msgs[0].ctrl.code == 200; if (success) { @@ -673,7 +666,7 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f case VIDEO: String posterUrl = null; - if (msgs[1] != null && msgs[1].ctrl != null && msgs[1].ctrl.code == 200){ + if (msgs[1] != null && msgs[1].ctrl != null && msgs[1].ctrl.code == 200) { posterUrl = msgs[1].ctrl.getStringParam("url", null); } content = draftyVideo(args.getString(ARG_IMAGE_CAPTION), uploadDetails.mimeType, @@ -958,8 +951,10 @@ static class UploadDetails { String valueRef; byte[] valueBits; + // Video poster. + String previewFileName; + int previewSize; String previewRef; byte[] previewBits; - int previewSize; } } \ No newline at end of file diff --git a/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java b/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java index b5b66c59..62f94111 100644 --- a/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java +++ b/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java @@ -30,6 +30,7 @@ import com.squareup.picasso.Callback; import com.squareup.picasso.Picasso; +import com.squareup.picasso.RequestCreator; import java.io.IOException; import java.io.InputStream; @@ -246,85 +247,91 @@ public void onGlobalLayout() { private void loadImage(final Activity activity, final Bundle args) { // Check if the bitmap is directly attached. int length = 0; - Bitmap bmp = args.getParcelable(AttachmentHandler.ARG_SRC_BITMAP); - if (bmp == null) { + Bitmap preview = args.getParcelable(AttachmentHandler.ARG_SRC_BITMAP); + if (preview == null) { // Check if bitmap is attached as an array of bytes (received). byte[] bits = args.getByteArray(AttachmentHandler.ARG_SRC_BYTES); if (bits != null) { - bmp = BitmapFactory.decodeByteArray(bits, 0, bits.length); + preview = BitmapFactory.decodeByteArray(bits, 0, bits.length); length = bits.length; } } - if (bmp == null) { - // Preview large image before sending. - Uri uri = args.getParcelable(AttachmentHandler.ARG_LOCAL_URI); - if (uri != null) { - // Local image. - final ContentResolver resolver = activity.getContentResolver(); - // Resize image to ensure it's under the maximum in-band size. - try { - InputStream is = resolver.openInputStream(uri); - if (is != null) { - bmp = BitmapFactory.decodeStream(is, null, null); - is.close(); - } - // Make sure the bitmap is properly oriented in preview. - is = resolver.openInputStream(uri); - if (is != null) { - ExifInterface exif = new ExifInterface(is); - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_UNDEFINED); - if (bmp != null) { - bmp = UiUtils.rotateBitmap(bmp, orientation); - } - is.close(); + // Preview large image before sending. + Bitmap bmp = null; + Uri uri = args.getParcelable(AttachmentHandler.ARG_LOCAL_URI); + if (uri != null) { + // Local image. + final ContentResolver resolver = activity.getContentResolver(); + // Resize image to ensure it's under the maximum in-band size. + try { + InputStream is = resolver.openInputStream(uri); + if (is != null) { + bmp = BitmapFactory.decodeStream(is, null, null); + is.close(); + } + // Make sure the bitmap is properly oriented in preview. + is = resolver.openInputStream(uri); + if (is != null) { + ExifInterface exif = new ExifInterface(is); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED); + if (bmp != null) { + bmp = UiUtils.rotateBitmap(bmp, orientation); } - } catch (IOException ex) { - Log.i(TAG, "Failed to read image from " + uri, ex); + is.close(); } - } else { - // Remote image. - final Uri ref = args.getParcelable(AttachmentHandler.ARG_REMOTE_URI); - if (ref != null) { - mRemoteState = RemoteState.LOADING; - Picasso.get().load(ref) - .error(R.drawable.ic_broken_image) - .into(mImageView, new Callback() { - @Override - public void onSuccess() { - mRemoteState = RemoteState.SUCCESS; - - Activity activity = getActivity(); - if (activity == null || activity.isFinishing() || activity.isDestroyed()) { - return; - } - - final Bitmap bmp = ((BitmapDrawable) mImageView.getDrawable()).getBitmap(); - mInitialRect = new RectF(0, 0, bmp.getWidth(), bmp.getHeight()); - mWorkingRect = new RectF(mInitialRect); - mMatrix.setRectToRect(mInitialRect, mScreenRect, Matrix.ScaleToFit.CENTER); - mWorkingMatrix = new Matrix(mMatrix); - mImageView.setImageMatrix(mMatrix); - mImageView.setScaleType(ImageView.ScaleType.MATRIX); - mImageView.enableOverlay(false); - - activity.findViewById(R.id.metaPanel).setVisibility(View.VISIBLE); - setupImagePostview(activity, args, bmp.getByteCount()); - } - - @Override - public void onError(Exception e) { - mRemoteState = RemoteState.FAILED; - Log.i(TAG, "Failed to fetch image: " + e.getMessage() + " (" + ref + ")"); - mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); - ((MenuHost) activity).removeMenuProvider(ImageViewFragment.this); - } - }); + } catch (IOException ex) { + Log.i(TAG, "Failed to read image from " + uri, ex); + } + } else { + // Remote image. + final Uri ref = args.getParcelable(AttachmentHandler.ARG_REMOTE_URI); + if (ref != null) { + mRemoteState = RemoteState.LOADING; + RequestCreator rc = Picasso.get().load(ref) + .error(R.drawable.ic_broken_image); + if (preview != null) { + rc = rc.placeholder(new BitmapDrawable(getResources(), preview)); } + rc.into(mImageView, new Callback() { + @Override + public void onSuccess() { + mRemoteState = RemoteState.SUCCESS; + + Activity activity = getActivity(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) { + return; + } + + final Bitmap bmp = ((BitmapDrawable) mImageView.getDrawable()).getBitmap(); + mInitialRect = new RectF(0, 0, bmp.getWidth(), bmp.getHeight()); + mWorkingRect = new RectF(mInitialRect); + mMatrix.setRectToRect(mInitialRect, mScreenRect, Matrix.ScaleToFit.CENTER); + mWorkingMatrix = new Matrix(mMatrix); + mImageView.setImageMatrix(mMatrix); + mImageView.setScaleType(ImageView.ScaleType.MATRIX); + mImageView.enableOverlay(false); + + activity.findViewById(R.id.metaPanel).setVisibility(View.VISIBLE); + setupImagePostview(activity, args, bmp.getByteCount()); + } + + @Override + public void onError(Exception e) { + mRemoteState = RemoteState.FAILED; + Log.i(TAG, "Failed to fetch image: " + e.getMessage() + " (" + ref + ")"); + mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + ((MenuHost) activity).removeMenuProvider(ImageViewFragment.this); + } + }); } } + if (bmp == null) { + bmp = preview; + } + if (bmp != null) { // Must ensure the bitmap is not too big (some cameras can produce // bigger bitmaps that the phone can render) From d44ed0d69162c459bd0f05c623afd8e770d02524 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 13:42:17 -0800 Subject: [PATCH 06/12] correctly use preview of image attachment --- .../co/tinode/tindroid/ImageViewFragment.java | 30 +++++++++---------- .../co/tinode/tindroid/VideoViewFragment.java | 1 + 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java b/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java index 62f94111..6af4d0a8 100644 --- a/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java +++ b/app/src/main/java/co/tinode/tindroid/ImageViewFragment.java @@ -289,16 +289,22 @@ private void loadImage(final Activity activity, final Bundle args) { final Uri ref = args.getParcelable(AttachmentHandler.ARG_REMOTE_URI); if (ref != null) { mRemoteState = RemoteState.LOADING; + mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); RequestCreator rc = Picasso.get().load(ref) .error(R.drawable.ic_broken_image); if (preview != null) { rc = rc.placeholder(new BitmapDrawable(getResources(), preview)); + // No need to show preview separately from Picasso. + preview = null; + } else { + rc = rc.placeholder(R.drawable.ic_image); } + rc.into(mImageView, new Callback() { @Override public void onSuccess() { mRemoteState = RemoteState.SUCCESS; - + Log.i(TAG, "Remote load: success" + ref); Activity activity = getActivity(); if (activity == null || activity.isFinishing() || activity.isDestroyed()) { return; @@ -320,7 +326,7 @@ public void onSuccess() { @Override public void onError(Exception e) { mRemoteState = RemoteState.FAILED; - Log.i(TAG, "Failed to fetch image: " + e.getMessage() + " (" + ref + ")"); + Log.w(TAG, "Failed to fetch image: " + e.getMessage() + " (" + ref + ")"); mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); ((MenuHost) activity).removeMenuProvider(ImageViewFragment.this); } @@ -374,14 +380,11 @@ public void onError(Exception e) { } else { mMatrix.setRectToRect(mInitialRect, mScreenRect, Matrix.ScaleToFit.CENTER); } - } else if (mRemoteState != RemoteState.SUCCESS) { - // Show placeholder or a broken image. - mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); - mImageView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), - mRemoteState == RemoteState.LOADING ? - R.drawable.ic_image : - R.drawable.ic_broken_image, - null)); + } else if (mRemoteState == RemoteState.NONE) { + // Local broken image. + mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + mImageView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), + R.drawable.ic_broken_image, null)); activity.findViewById(R.id.metaPanel).setVisibility(View.INVISIBLE); ((MenuHost) activity).removeMenuProvider(this); } @@ -431,16 +434,13 @@ private void setupImagePostview(final Activity activity, Bundle args, long lengt @Override public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - // Inflate the menu; this adds items to the action bar if it is present. + menu.clear(); inflater.inflate(R.menu.menu_download, menu); } @Override public boolean onMenuItemSelected(@NonNull MenuItem item) { - final Activity activity = getActivity(); - if (activity == null) { - return false; - } + final Activity activity = requireActivity(); if (item.getItemId() == R.id.action_download) { // Save image to Gallery. diff --git a/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java b/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java index 3f134c30..8cc7dd9b 100644 --- a/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java +++ b/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java @@ -280,6 +280,7 @@ public void onPrepareMenu(@NonNull Menu menu) { @Override public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menu.clear(); menuInflater.inflate(R.menu.menu_download, menu); } From ef873e05e36aff274f8db8c6d3f453b7f0936e62 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 14:17:42 -0800 Subject: [PATCH 07/12] fix reporting video size (was rotated) --- .../java/co/tinode/tindroid/VideoViewFragment.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java b/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java index 8cc7dd9b..7367873b 100644 --- a/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java +++ b/app/src/main/java/co/tinode/tindroid/VideoViewFragment.java @@ -39,6 +39,7 @@ import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.video.VideoSize; import com.squareup.picasso.Picasso; import java.io.BufferedOutputStream; @@ -123,10 +124,11 @@ public void onPlaybackStateChanged(int playbackState) { // Local video may be ready before menu is ready. mDownloadMenuItem.setEnabled(true); } - Format fmt = mExoPlayer.getVideoFormat(); - if (fmt != null) { - mVideoWidth = fmt.width; - mVideoHeight = fmt.height; + + VideoSize vs = mExoPlayer.getVideoSize(); + if (vs.width > 0 && vs.height > 0 ) { + mVideoWidth = vs.width; + mVideoHeight = vs.height; } else { Log.w(TAG, "Unable to read video dimensions"); } From f94956410bf1c8a608c48d07e9d15d894775e55b Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 14:42:32 -0800 Subject: [PATCH 08/12] remove ref & preref from quoted image and video --- .../co/tinode/tinodesdk/model/Drafty.java | 6 ++++ .../co/tinode/tinodesdk/model/DraftyTest.java | 31 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java b/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java index 5ffe8cb7..47f30056 100644 --- a/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java +++ b/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java @@ -1529,6 +1529,12 @@ public Drafty replyContent(int length, int maxAttachments) { node.text = new StringBuilder(" "); node.tp = null; node.children = null; + } else if (node.isStyle("IM") || node.isStyle("VD")) { + if (node.data != null) { + // Do not rend references to out-of-band large images. + node.data.remove("ref"); + node.data.remove("preref"); + } } return node; } diff --git a/tinodesdk/src/test/java/co/tinode/tinodesdk/model/DraftyTest.java b/tinodesdk/src/test/java/co/tinode/tinodesdk/model/DraftyTest.java index 3f1d786f..9be8f459 100644 --- a/tinodesdk/src/test/java/co/tinode/tinodesdk/model/DraftyTest.java +++ b/tinodesdk/src/test/java/co/tinode/tinodesdk/model/DraftyTest.java @@ -513,7 +513,7 @@ public void testReply() { }; assertEquals("Reply 4 has failed", expected, actual); - // ------- Reply 5 (inline image) + // ------- Reply 5 (inline image with in-band bits only) src = new Drafty(" "); src.fmt = new Drafty.Style[]{ new Drafty.Style(0, 1, 0), @@ -540,6 +540,35 @@ public void testReply() { .putData("mime", "image/jpeg"), }; assertEquals("Reply 5 has failed", expected, actual); + + // ------- Reply 6 (inline image with in-band preview and out of band reference) + src = new Drafty(" "); + src.fmt = new Drafty.Style[]{ + new Drafty.Style(0, 1, 0), + }; + src.ent = new Drafty.Entity[]{ + new Drafty.Entity("IM") + .putData("height", 213) + .putData("width", 638) + .putData("name", "roses.jpg") + .putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>") + .putData("ref", "/v0/file/s/77A4SDFXfzY.jpe") + .putData("mime", "image/jpeg"), + }; + actual = src.replyContent(25, 3); + expected = new Drafty(" "); + expected.fmt = new Drafty.Style[]{ + new Drafty.Style(0, 1, 0), + }; + expected.ent = new Drafty.Entity[]{ + new Drafty.Entity("IM") + .putData("height", 213) + .putData("width", 638) + .putData("name", "roses.jpg") + .putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>") + .putData("mime", "image/jpeg"), + }; + assertEquals("Reply 6 has failed", expected, actual); } @Test From 0d02c978fd24f095f70d0f558aaf387ee21f9514 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 15:01:27 -0800 Subject: [PATCH 09/12] make message bubble margins equal --- app/src/main/res/layout/message_left.xml | 2 +- app/src/main/res/layout/message_left_avatar.xml | 2 +- app/src/main/res/layout/message_left_single.xml | 2 +- app/src/main/res/layout/message_left_single_avatar.xml | 2 +- app/src/main/res/layout/message_right.xml | 2 +- app/src/main/res/layout/message_right_single.xml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/layout/message_left.xml b/app/src/main/res/layout/message_left.xml index fdb908d8..a424df7e 100644 --- a/app/src/main/res/layout/message_left.xml +++ b/app/src/main/res/layout/message_left.xml @@ -35,7 +35,7 @@ android:focusable="false" android:maxWidth="280dp" android:minWidth="112dp" - android:paddingStart="18dp" + android:paddingStart="14dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" diff --git a/app/src/main/res/layout/message_left_avatar.xml b/app/src/main/res/layout/message_left_avatar.xml index 4a69f75c..2b04b0c2 100644 --- a/app/src/main/res/layout/message_left_avatar.xml +++ b/app/src/main/res/layout/message_left_avatar.xml @@ -36,7 +36,7 @@ android:focusable="false" android:maxWidth="270dp" android:minWidth="102dp" - android:paddingStart="18dp" + android:paddingStart="16.5dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" diff --git a/app/src/main/res/layout/message_left_single.xml b/app/src/main/res/layout/message_left_single.xml index 83d7114a..0e3fed6e 100644 --- a/app/src/main/res/layout/message_left_single.xml +++ b/app/src/main/res/layout/message_left_single.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:maxWidth="280dp" android:minWidth="112dp" - android:paddingStart="18dp" + android:paddingStart="14dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" diff --git a/app/src/main/res/layout/message_left_single_avatar.xml b/app/src/main/res/layout/message_left_single_avatar.xml index 500f1f8c..9e2fcd35 100644 --- a/app/src/main/res/layout/message_left_single_avatar.xml +++ b/app/src/main/res/layout/message_left_single_avatar.xml @@ -53,7 +53,7 @@ android:focusable="false" android:maxWidth="270dp" android:minWidth="102dp" - android:paddingStart="8dp" + android:paddingStart="4dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" diff --git a/app/src/main/res/layout/message_right.xml b/app/src/main/res/layout/message_right.xml index 280b3618..22ee7f20 100644 --- a/app/src/main/res/layout/message_right.xml +++ b/app/src/main/res/layout/message_right.xml @@ -30,7 +30,7 @@ android:layout_height="wrap_content" android:maxWidth="280dp" android:minWidth="112dp" - android:paddingStart="8dp" + android:paddingStart="4dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" diff --git a/app/src/main/res/layout/message_right_single.xml b/app/src/main/res/layout/message_right_single.xml index d2926b64..a0360ade 100644 --- a/app/src/main/res/layout/message_right_single.xml +++ b/app/src/main/res/layout/message_right_single.xml @@ -35,7 +35,7 @@ android:layout_height="wrap_content" android:maxWidth="280dp" android:minWidth="112dp" - android:paddingStart="8dp" + android:paddingStart="4dp" android:paddingEnd="4dp" android:textColor="?android:textColorPrimary" tools:ignore="RtlSymmetry" From 1f067a6077da6c54a9091532254f0603ddfa1ea8 Mon Sep 17 00:00:00 2001 From: or-else Date: Sun, 19 Feb 2023 16:48:55 -0800 Subject: [PATCH 10/12] fix image jumping on click --- app/build.gradle | 2 +- .../co/tinode/tindroid/MessagesAdapter.java | 12 +- .../format/StableLinkMovementMethod.java | 186 ++++++++++++++++++ app/src/main/res/values/ids.xml | 1 + 4 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/co/tinode/tindroid/format/StableLinkMovementMethod.java diff --git a/app/build.gradle b/app/build.gradle index 4b3a5c6f..29aa9702 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -100,7 +100,7 @@ dependencies { implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' implementation 'com.google.android.material:material:1.7.0' implementation 'com.google.firebase:firebase-core:21.1.1' - implementation 'com.google.firebase:firebase-crashlytics:18.3.2' + implementation 'com.google.firebase:firebase-crashlytics:18.3.5' implementation 'com.google.firebase:firebase-messaging:23.1.1' // Don't change to 2.7182... The 2.8 is the latest. diff --git a/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java b/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java index d9a6b023..70d95aef 100644 --- a/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java +++ b/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java @@ -3,7 +3,6 @@ import android.Manifest; import android.animation.ValueAnimator; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -14,10 +13,6 @@ import android.graphics.Point; import android.graphics.Typeface; import android.graphics.drawable.Drawable; -import android.media.AudioAttributes; -import android.media.AudioManager; -import android.media.MediaDataSource; -import android.media.MediaPlayer; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -30,7 +25,6 @@ import android.text.style.ForegroundColorSpan; import android.text.style.IconMarginSpan; import android.text.style.StyleSpan; -import android.util.Base64; import android.util.Log; import android.util.SparseBooleanArray; import android.view.GestureDetector; @@ -75,10 +69,10 @@ import co.tinode.tindroid.db.BaseDb; import co.tinode.tindroid.db.MessageDb; import co.tinode.tindroid.db.StoredMessage; -import co.tinode.tindroid.db.TopicDb; import co.tinode.tindroid.format.CopyFormatter; import co.tinode.tindroid.format.FullFormatter; import co.tinode.tindroid.format.QuoteFormatter; +import co.tinode.tindroid.format.StableLinkMovementMethod; import co.tinode.tindroid.format.ThumbnailTransformer; import co.tinode.tindroid.media.VxCard; import co.tinode.tinodesdk.ComTopic; @@ -618,7 +612,9 @@ public void onBindViewHolder(@NonNull final ViewHolder holder, int position) { holder.mGestureDetector.onTouchEvent(ev); return false; }); - holder.mText.setMovementMethod(LinkMovementMethod.getInstance()); + // This causes the image to shift left. + //holder.mText.setMovementMethod(LinkMovementMethod.getInstance()); + holder.mText.setMovementMethod(StableLinkMovementMethod.getInstance()); holder.mText.setLinksClickable(true); holder.mText.setFocusable(true); holder.mText.setClickable(true); diff --git a/app/src/main/java/co/tinode/tindroid/format/StableLinkMovementMethod.java b/app/src/main/java/co/tinode/tindroid/format/StableLinkMovementMethod.java new file mode 100644 index 00000000..e279a188 --- /dev/null +++ b/app/src/main/java/co/tinode/tindroid/format/StableLinkMovementMethod.java @@ -0,0 +1,186 @@ +package co.tinode.tindroid.format; + +import android.graphics.RectF; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.style.BackgroundColorSpan; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.view.MotionEvent; +import android.widget.TextView; + +import co.tinode.tindroid.R; + +/** + * Fixes two bugs in LinkMovementMethod: + * + * Highlights clicked URLSpans only. + * LinkMovementMethod tries to highlight any ClickableSpan resulting in + * clickable images jumping left. + * + * Correctly identifies URL bounds. + * LinkMovementMethod registers a click made outside of the URL's bounds + * if there is no more text in that direction. + */ +public class StableLinkMovementMethod extends LinkMovementMethod { + + private static StableLinkMovementMethod sSharedInstance; + + private final RectF mTouchedLineBounds = new RectF(); + private boolean mIsUrlHighlighted; + private ClickableSpan mClickableSpanUnderTouchOnActionDown; + private int mActiveTextViewHashcode; + + /** + * Get a shared instance of StableLinkMovementMethod. + */ + public static StableLinkMovementMethod getInstance() { + if (sSharedInstance == null) { + sSharedInstance = new StableLinkMovementMethod(); + } + return sSharedInstance; + } + + protected StableLinkMovementMethod() { + } + + @Override + public boolean onTouchEvent(final TextView textView, Spannable text, MotionEvent event) { + if (mActiveTextViewHashcode != textView.hashCode()) { + // Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted. + // A hacky solution is to reset any "autoLink" property set in XML. But we also want + // to do this once per TextView. + mActiveTextViewHashcode = textView.hashCode(); + textView.setAutoLinkMask(0); + } + + final ClickableSpan clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + mClickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch; + } + final boolean touchStartedOverAClickableSpan = mClickableSpanUnderTouchOnActionDown != null; + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (clickableSpanUnderTouch instanceof URLSpan) { + highlightUrl(textView, clickableSpanUnderTouch, text); + } + + return touchStartedOverAClickableSpan; + + case MotionEvent.ACTION_UP: + // Register a click only if the touch started and ended on the same URL. + if (touchStartedOverAClickableSpan && + clickableSpanUnderTouch == mClickableSpanUnderTouchOnActionDown) { + clickableSpanUnderTouch.onClick(textView); + } + cleanupOnTouchUp(textView); + + // Consume this event even if we could not find any spans to avoid letting Android handle this event. + // Android's TextView implementation has a bug where links get clicked even when there is no more text + // next to the link and the touch lies outside its bounds in the same direction. + return touchStartedOverAClickableSpan; + + case MotionEvent.ACTION_CANCEL: + cleanupOnTouchUp(textView); + return false; + + case MotionEvent.ACTION_MOVE: + // Toggle highlight. + if (clickableSpanUnderTouch != null) { + highlightUrl(textView, clickableSpanUnderTouch, text); + } else { + removeUrlHighlightColor(textView); + } + + return touchStartedOverAClickableSpan; + + default: + return false; + } + } + + private void cleanupOnTouchUp(TextView textView) { + mClickableSpanUnderTouchOnActionDown = null; + removeUrlHighlightColor(textView); + } + + /** + * Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any). + * + * @return The touched ClickableSpan or null. + */ + protected ClickableSpan findClickableSpanUnderTouch(TextView textView, Spannable text, MotionEvent event) { + // Find the location in text where touch was made, regardless of whether the TextView + // has scrollable text. That is, not the entire text is currently visible. + int touchX = (int) event.getX(); + int touchY = (int) event.getY(); + + // Ignore padding. + touchX -= textView.getTotalPaddingLeft(); + touchY -= textView.getTotalPaddingTop(); + + // Account for scrollable text. + touchX += textView.getScrollX(); + touchY += textView.getScrollY(); + + final Layout layout = textView.getLayout(); + final int touchedLine = layout.getLineForVertical(touchY); + final int touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX); + + mTouchedLineBounds.left = layout.getLineLeft(touchedLine); + mTouchedLineBounds.top = layout.getLineTop(touchedLine); + mTouchedLineBounds.right = layout.getLineWidth(touchedLine) + mTouchedLineBounds.left; + mTouchedLineBounds.bottom = layout.getLineBottom(touchedLine); + + if (mTouchedLineBounds.contains(touchX, touchY)) { + // Find a ClickableSpan that lies under the touched area. + final Object[] spans = text.getSpans(touchOffset, touchOffset, ClickableSpan.class); + for (final Object span : spans) { + if (span instanceof ClickableSpan) { + return (ClickableSpan) span; + } + } + } + + // No ClickableSpan found under. + return null; + } + + /** + * Adds a background color span at clickableSpan's location. + */ + protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) { + if (mIsUrlHighlighted) { + return; + } + mIsUrlHighlighted = true; + + int spanStart = text.getSpanStart(clickableSpan); + int spanEnd = text.getSpanEnd(clickableSpan); + BackgroundColorSpan highlightSpan = new BackgroundColorSpan(textView.getHighlightColor()); + text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + textView.setTag(R.id.highlight_background_span, highlightSpan); + + Selection.setSelection(text, spanStart, spanEnd); + } + + /** + * Removes the highlight color under the Url. + */ + protected void removeUrlHighlightColor(TextView textView) { + if (!mIsUrlHighlighted) { + return; + } + mIsUrlHighlighted = false; + + Spannable text = (Spannable) textView.getText(); + BackgroundColorSpan highlightSpan = (BackgroundColorSpan) textView.getTag(R.id.highlight_background_span); + text.removeSpan(highlightSpan); + + Selection.removeSelection(text); + } +} \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 6cc01018..e0a43ec7 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + \ No newline at end of file From 8fc26707fb8c6053462a74040270bf57c3720166 Mon Sep 17 00:00:00 2001 From: or-else Date: Mon, 20 Feb 2023 13:30:55 -0800 Subject: [PATCH 11/12] better handling of failed upload --- .../co/tinode/tindroid/AttachmentHandler.java | 15 ++++++++------- .../java/co/tinode/tindroid/MessagesAdapter.java | 7 +++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java b/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java index 9e61789e..ee171309 100644 --- a/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java +++ b/app/src/main/java/co/tinode/tindroid/AttachmentHandler.java @@ -607,11 +607,11 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f // Upload results. // noinspection unchecked - PromisedReply[] uploadResults = (PromisedReply[]) new PromisedReply[2]; + PromisedReply[] uploadPromises = (PromisedReply[]) new PromisedReply[2]; // Upload large media. mUploader = Cache.getTinode().getLargeFileHelper(); - uploadResults[0] = mUploader.uploadAsync(is, uploadDetails.fileName, + uploadPromises[0] = mUploader.uploadAsync(is, uploadDetails.fileName, uploadDetails.mimeType, uploadDetails.fileSize, topicName, (progress, size) -> setProgressAsync(new Data.Builder() .putAll(result.build()) @@ -621,22 +621,23 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f // Optionally upload video poster. if (uploadDetails.previewRef != null) { - uploadResults[1] = mUploader.uploadAsync(new ByteArrayInputStream(uploadDetails.previewBits), + uploadPromises[1] = mUploader.uploadAsync(new ByteArrayInputStream(uploadDetails.previewBits), "poster", uploadDetails.previewMime, uploadDetails.previewSize, topicName, null); // ByteArrayInputStream:close() is a noop. No need to call close(). } else { - uploadResults[1] = null; + uploadPromises[1] = null; } ServerMessage[] msgs = new ServerMessage[2]; try { // Wait for uploads to finish. This is a long-running blocking call. - Object[] objs = PromisedReply.allOf(uploadResults).getResult(); + Object[] objs = PromisedReply.allOf(uploadPromises).getResult(); msgs[0] = (ServerMessage) objs[0]; msgs[1] = (ServerMessage) objs[1]; } catch (Exception ex) { - throw new CancellationException(); + store.msgFailed(topic, msgId); + throw ex; } mUploader = null; @@ -693,7 +694,7 @@ private ListenableWorker.Result uploadMessageAttachment(final Context context, f } catch (CancellationException ignored) { result.putString(ARG_ERROR, context.getString(R.string.canceled)); Log.d(TAG, "Upload cancelled"); - } catch (IOException | SecurityException | IllegalArgumentException ex) { + } catch (Exception ex) { result.putString(ARG_ERROR, ex.getMessage()); Log.w(TAG, "Failed to upload file", ex); } finally { diff --git a/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java b/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java index 70d95aef..e5392d00 100644 --- a/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java +++ b/app/src/main/java/co/tinode/tindroid/MessagesAdapter.java @@ -637,10 +637,13 @@ public void onBindViewHolder(@NonNull final ViewHolder holder, int position) { holder.mCancelProgress.setOnClickListener(v -> { cancelUpload(msgId); holder.mProgress.setVisibility(View.GONE); + // Show 'canceled'. + holder.mProgressResult.setText(R.string.canceled); holder.mProgressResult.setVisibility(View.VISIBLE); }); } else if (uploadFailed) { - // Show the word 'canceled'. + // Show 'failed'. + holder.mProgressResult.setText(R.string.failed); holder.mProgressResult.setVisibility(View.VISIBLE); // Hide progress bar. holder.mProgress.setVisibility(View.GONE); @@ -1032,7 +1035,7 @@ static class ViewHolder extends RecyclerView.ViewHolder { final ProgressBar mProgressBar; final AppCompatImageButton mCancelProgress; final View mProgress; - final View mProgressResult; + final TextView mProgressResult; final GestureDetector mGestureDetector; int seqId = 0; From 612b433d37f82aaadc5342fbdc99b45d66da0e52 Mon Sep 17 00:00:00 2001 From: aforge Date: Mon, 20 Feb 2023 19:38:54 -0800 Subject: [PATCH 12/12] CallFrament: check if data channel is available before sending data. --- app/src/main/java/co/tinode/tindroid/CallFragment.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/co/tinode/tindroid/CallFragment.java b/app/src/main/java/co/tinode/tindroid/CallFragment.java index 8095523b..0e5e0800 100644 --- a/app/src/main/java/co/tinode/tindroid/CallFragment.java +++ b/app/src/main/java/co/tinode/tindroid/CallFragment.java @@ -642,8 +642,12 @@ private void handleSendAnswer(SessionDescription sd) { } private void sendToPeer(String msg) { - mDataChannel.send(new DataChannel.Buffer( - ByteBuffer.wrap(msg.getBytes(StandardCharsets.UTF_8)), false)); + if (mDataChannel != null) { + mDataChannel.send(new DataChannel.Buffer( + ByteBuffer.wrap(msg.getBytes(StandardCharsets.UTF_8)), false)); + } else { + Log.w(TAG, "Data channel is null. Peer will not receive the message: '" + msg + "'"); + } } // Data channel observer for receiving video mute/unmute events.