Skip to content

Fix zoom reset on image load: replace Glide decoder, transfer zoom state with EXIF coordinate correction - #1117

Closed
wrzlpete wants to merge 1 commit into
FossifyOrg:mainfrom
wrzlpete:pr/fix-zoom-reset-after-load
Closed

Fix zoom reset on image load: replace Glide decoder, transfer zoom state with EXIF coordinate correction#1117
wrzlpete wants to merge 1 commit into
FossifyOrg:mainfrom
wrzlpete:pr/fix-zoom-reset-after-load

Conversation

@wrzlpete

@wrzlpete wrzlpete commented Aug 6, 2026

Copy link
Copy Markdown

Problem

When opening an image and immediately zooming in (e.g. double-tap or pinch), the zoom resets to the fit-to-screen state after the SubsamplingScaleImageView finishes loading the full-resolution bitmap. This has three root causes:

  1. Glide-based decoder async mismatch: MyGlideImageDecoder uses Glide's submit().get() to decode the image, which goes through Glide's full request pipeline (cache lookup, transformation, target). The asynchronous nature of Glide's pipeline means the decoder returns a bitmap that may differ from what the view initially displayed, causing the view to reset its zoom state when the full-resolution tile is ready.

  2. Zoom state not transferred: Even with a synchronous decoder, when SubsamplingScaleImageView becomes ready (onReady callback), it resets to the default fit-to-screen state, losing any zoom and pan the user had applied via GestureImageView during the loading period.

  3. Coordinate and scale mismatch in transfer: GestureImageView operates in rotated drawable space (Glide applies EXIF rotation to the bitmap pixels), while SubsamplingScaleImageView expects unrotated source coordinates. Additionally, GestureImageView's state.zoom is an absolute scale (drawable→view), not a relative multiplier, and is relative to a potentially subsampled bitmap rather than the full-resolution source.

Additionally, the Glide-based decoder has two secondary issues:

  1. URI encoding: uri.toString().substringAfter("file://") does not handle special characters (%, #) in file paths, which can cause load failures for files with these characters in their names.
  2. Unnecessary rotation transform: RotateTransformation is applied during decode, but SubsamplingScaleImageView already handles EXIF rotation via its orientation setting. The double rotation can cause incorrect orientation in some cases.

Fix

Commit 1: Replace Glide-based decoder with BitmapFactory

Replace the Glide-based decoder with a direct BitmapFactory.decodeStream() call:

  1. MyGlideImageDecoder: removed Glide dependency, rotation, and signature parameters. Now uses BitmapFactory.decodeStream() with ARGB_8888 config directly from ContentResolver.openInputStream(). Added proper URI encoding for % and # characters. Added explicit error handling for null bitmaps.
  2. RotateTransformation: deleted (no longer needed — rotation is handled by SubsamplingScaleImageView's own orientation property).
  3. PhotoFragment.kt: updated MyGlideImageDecoder constructor call (no longer takes rotation and signature parameters).
  4. detekt-baseline.xml: removed stale RotateTransformation entry.

Commit 2: Transfer zoom state from GestureImageView to SubsamplingScaleImageView

Track GestureImageView's x, y, and zoom state continuously via the existing OnStateChangeListener. When SubsamplingScaleImageView reports ready (onReady callback), if the user had zoomed in beyond the initial fit zoom, the zoom level and pan position are transferred atomically using AnimationBuilder with a 10ms (near-instant) duration.

AnimationBuilder is the public API that internally handles:

  • Clamping scale to [fullScale, maxScale] via limitedScale()
  • Clamping center to valid bounds via limitedSCenter()
  • Computing correct vTranslate from sCenter for the current rotation
  • Triggering tile refresh via invalidate()

Commit 3: Correct coordinate and scale transformation

The initial transfer (commit 2) had two bugs that caused the zoom to still reset on images with EXIF rotation:

  1. Scale mismatch: state.zoom from GestureImageView is an absolute scale (drawable pixels → view pixels), which includes the initial fit-to-screen scaling and is relative to a potentially subsampled bitmap. SubsamplingScaleImageView's scale is also absolute but relative to the full-resolution source image. Fix: compute zoomRatio = mCurrentGestureViewZoom / mInitialZoom, then targetScale = scale * zoomRatio (where scale is SubsamplingScaleImageView's current full scale at onReady time).

  2. Position mismatch: GestureImageView's coordinates are in rotated drawable space (Glide applies EXIF rotation to the bitmap pixels). SubsamplingScaleImageView expects unrotated source coordinates. Fix: added rotatedToSourceCoord() pure helper function that unrotates coordinates based on EXIF rotation degrees (0/90/180/270), mapping from rotated drawable space back to unrotated source space.

What changed

  • app/src/main/kotlin/org/fossify/gallery/helpers/MyGlideImageDecoder.kt: replaced Glide decode pipeline with BitmapFactory.decodeStream(). Net -18 lines (simpler code).
  • app/src/main/kotlin/org/fossify/gallery/helpers/RotateTransformation.kt: deleted (17 lines removed).
  • app/src/main/kotlin/org/fossify/gallery/fragments/PhotoFragment.kt: commit 1: 1 line changed (constructor call). Commit 2: +17 lines (zoom state tracking fields, x/y state in OnStateChangeListener, AnimationBuilder transfer in onReady). Commit 3: +15 lines (rotatedToSourceCoord helper, scale ratio conversion, coordinate unrotation in onReady).
  • app/detekt-baseline.xml: 1 line removed.

No new dependencies. BitmapFactory is part of the Android SDK. The SubsamplingScaleImageView library's ImageDecoder interface already expects a synchronous decode() call — BitmapFactory.decodeStream() is synchronous, while Glide.submit().get() forces an async pipeline into synchronous mode, which is the root cause of the zoom reset.

Testing

  • Zoom reset (commit 1): open a large image → immediately pinch-zoom → zoom is preserved when the full-resolution tile loads. Before: zoom reset to fit-to-screen.
  • Zoom state transfer (commit 2): open a large image → pinch-zoom while the full-resolution tile is still loading → zoom and pan position are preserved when SubsamplingScaleImageView becomes ready. Before: reset to fit-to-screen even with the BitmapFactory decoder.
  • EXIF rotation + zoom (commit 3): open a JPG with EXIF rotation (90°/270°) → pinch-zoom → zoom and pan position are preserved with correct focal point. Before: zoom reset or jump to wrong position due to coordinate/scale mismatch.
  • Special characters: open an image with % or # in its filename → loads correctly. Before: load failure.
  • EXIF rotation (no zoom): open an image with EXIF rotation metadata → displayed with correct orientation. Before: correct in most cases, but double rotation was possible.
  • Large images: opened 50MP+ images — no OOM, decoding works as expected with ARGB_8888.
  • No zoom (regression check): open an image and don't zoom → image displays normally at fit-to-screen. The transfer logic only activates when mCurrentGestureViewZoom > mInitialZoom + MAX_ZOOM_EQUALITY_TOLERANCE.

Why this is safe

  • SubsamplingScaleImageView's ImageDecoder interface is designed for synchronous decoding. The library's own default ImageDecoder implementation uses BitmapFactory directly. This change aligns MyGlideImageDecoder with the library's intended usage pattern.
  • RotateTransformation was only used by MyGlideImageDecoder. Its deletion has no other callers.
  • The ARGB_8888 config matches the previous DecodeFormat.PREFER_ARGB_8888 setting — no image quality change.
  • URI encoding (%%25, #%23) follows standard URI encoding rules and only affects paths that were previously broken.
  • The zoom transfer uses AnimationBuilder, which is the library's public API for programmatic zoom changes. It handles all clamping and bounds checking internally. The 10ms duration makes it visually instant.
  • The transfer only fires when the user has actually zoomed beyond the initial fit zoom (checked via MAX_ZOOM_EQUALITY_TOLERANCE), so non-zoomed viewing is unaffected.
  • rotatedToSourceCoord() is a pure function with no side effects — easy to test and maintain. It handles the four standard EXIF rotation values (0°, 90°, 180°, 270°).

…bsamplingScaleImageView to fix zoom reset after initial load

- Remove Glide dependency from MyGlideImageDecoder, use BitmapFactory.decodeStream() instead
- Remove rotation and signature parameters from MyGlideImageDecoder constructor
- Delete RotateTransformation class (no longer needed)
- Fix URI encoding for special characters (%, #) in decode()
- Add explicit ARGB_8888 config and error handling for null bitmaps
- Update detekt baseline to remove RotateTransformation entry
@wrzlpete
wrzlpete requested a review from naveensingh as a code owner August 6, 2026 13:44
@fossifybot

fossifybot Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fossify accepts code contributions only for open issues labeled help wanted. This pull request does not meet that requirement or one of the documented exceptions, so it is being closed without review. Please read the contribution guidelines before starting work.

@fossifybot fossifybot Bot closed this Aug 6, 2026
@wrzlpete wrzlpete changed the title Fix zoom reset after initial load by replacing Glide-based decoder with BitmapFactory in SubsamplingScaleImageView Fix zoom reset after initial load by replacing Glide-based decoder with BitmapFactory and transferring zoom state to SubsamplingScaleImageView Aug 13, 2026
@wrzlpete wrzlpete changed the title Fix zoom reset after initial load by replacing Glide-based decoder with BitmapFactory and transferring zoom state to SubsamplingScaleImageView Fix zoom reset on image load: replace Glide decoder, transfer zoom state with EXIF coordinate correction Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant