Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.18.7

* Fixes corrupted and missing marker icons on maps with many markers created from
`BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same
bytes and scaling.

## 2.18.6

* Updates pigeon dev_dependency to ^27.3.2 for analyzer 14 compatibility.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,60 @@ import Testing
#expect(resultImage?.size.height == 1.0)
}

@Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon() -> UIImage? {
let bitmap = FGMPlatformBitmapBytesMap.make(
withByteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: 1,
width: nil,
height: nil
)
return FGMIconFromBitmap(
FGMPlatformBitmap.make(withBitmap: bitmap),
TestAssetProvider(),
screenScale
)
}

let firstImage = try #require(createIcon())
let secondImage = try #require(createIcon())

// The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe
// the same image must share a single instance.
#expect(firstImage === secondImage)
}

@Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon(imagePixelRatio: Double) -> UIImage? {
let bitmap = FGMPlatformBitmapBytesMap.make(
withByteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: imagePixelRatio,
width: nil,
height: nil
)
return FGMIconFromBitmap(
FGMPlatformBitmap.make(withBitmap: bitmap),
TestAssetProvider(),
screenScale
)
}

let firstImage = try #require(createIcon(imagePixelRatio: 1))
let secondImage = try #require(createIcon(imagePixelRatio: 10))

#expect(firstImage !== secondImage)
#expect(firstImage.scale == 1.0)
#expect(secondImage.scale == 10.0)
}

/// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+.
/// On earlier versions, FGMIconFromBitmap returns nil for PinConfig, which is expected behavior.
@Test func extractIconFromPinConfigWithGlyphColor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#import "FGMImageUtils.h"
#import "FGMConversionUtils.h"

#import <CommonCrypto/CommonDigest.h>

@import Foundation;

/// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the
Expand Down Expand Up @@ -46,6 +48,21 @@
static UIImage *scaledImageWithWidthHeight(UIImage *image, NSNumber *width, NSNumber *height,
CGFloat screenScale);

/// Returns the cache that shares one UIImage between all bitmaps that describe the same image.
///
/// The Maps SDK allocates marker texture space per UIImage instance instead of per image content,
/// so creating a new UIImage for every marker exhausts the SDK's texture atlases ("Reached the max
/// number of texture atlases, can not allocate more.") and markers are drawn with the contents of
/// other markers. Sharing one instance between identical bitmaps avoids that, and is safe because
/// UIImage is immutable.
///
/// NSCache is thread-safe, and releases its contents when the system is under memory pressure.
static NSCache<NSString *, UIImage *> *FGMBytesMapIconCache(void);

/// Returns a key that covers every input of the icon created for [bitmap], so that bitmaps that
/// would produce different images never share one.
static NSString *FGMBytesMapIconCacheKey(FGMPlatformBitmapBytesMap *bitmap, CGFloat screenScale);

UIImage *FGMIconFromBitmap(FGMPlatformBitmap *platformBitmap,
NSObject<FGMAssetProvider> *assetProvider, CGFloat screenScale) {
assert(screenScale > 0 && "Screen scale must be greater than 0");
Expand Down Expand Up @@ -106,6 +123,12 @@
FGMPlatformBitmapBytesMap *bitmapBytesMap = bitmap;
FlutterStandardTypedData *bytes = bitmapBytesMap.byteData;

NSString *cacheKey = FGMBytesMapIconCacheKey(bitmapBytesMap, screenScale);
UIImage *cachedIcon = [FGMBytesMapIconCache() objectForKey:cacheKey];
if (cachedIcon) {
return cachedIcon;
}

@try {
image = [UIImage imageWithData:bytes.data scale:screenScale];
if (bitmapBytesMap.bitmapScaling == FGMPlatformMapBitmapScalingAuto) {
Expand All @@ -128,6 +151,9 @@
reason:@"Unable to interpret bytes as a valid image."
userInfo:nil];
}
if (image) {
[FGMBytesMapIconCache() setObject:image forKey:cacheKey];
}
} else if ([bitmap isKindOfClass:[FGMPlatformBitmapPinConfig class]]) {
FGMPlatformBitmapPinConfig *pinConfig = bitmap;

Expand Down Expand Up @@ -167,6 +193,27 @@
return image;
}

static NSCache<NSString *, UIImage *> *FGMBytesMapIconCache(void) {
static NSCache<NSString *, UIImage *> *cache;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
cache = [[NSCache alloc] init];
});
return cache;
}

static NSString *FGMBytesMapIconCacheKey(FGMPlatformBitmapBytesMap *bitmap, CGFloat screenScale) {
NSData *data = bitmap.byteData.data;
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
NSData *digestData = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
NSString *contentHash = [digestData base64EncodedStringWithOptions:0];
long scaling = (long)bitmap.bitmapScaling;
return
[NSString stringWithFormat:@"%@|%ld|%f|%@|%@|%f", contentHash, scaling,
bitmap.imagePixelRatio, bitmap.width, bitmap.height, screenScale];
}
Comment on lines +205 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure robust defensive programming, we should guard against potential nil or empty NSData before calling CC_SHA256. Passing a NULL pointer to CC_SHA256 (which happens if data is nil or empty) can trigger static analysis warnings or undefined behavior depending on the environment. Checking data.length > 0 before hashing is a safer approach.

static NSString *FGMBytesMapIconCacheKey(FGMPlatformBitmapBytesMap *bitmap, CGFloat screenScale) {
  NSData *data = bitmap.byteData.data;
  NSString *contentHash = @"";
  if (data.length > 0) {
    unsigned char digest[CC_SHA256_DIGEST_LENGTH];
    CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
    NSData *digestData = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
    contentHash = [digestData base64EncodedStringWithOptions:0];
  }
  long scaling = (long)bitmap.bitmapScaling;
  return
      [NSString stringWithFormat:@"%@|%ld|%f|%@|%@|%f", contentHash, scaling,
                                 bitmap.imagePixelRatio, bitmap.width, bitmap.height, screenScale];
}


UIImage *scaledImage(UIImage *image, double scale) {
if (fabs(scale - 1) > 1e-3) {
return [UIImage imageWithCGImage:[image CGImage]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: google_maps_flutter_ios
description: iOS implementation of the google_maps_flutter plugin.
repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22
version: 2.18.6
version: 2.18.7

environment:
sdk: ^3.10.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.18.14

* Fixes corrupted and missing marker icons on maps with many markers created from
`BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same
bytes and scaling.

## 2.18.13

* Adopts new Pigeon async Swift support.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,50 @@ import Testing
#expect(resultImage?.size.height == 1.0)
}

@Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon() -> UIImage? {
PlatformBitmapBytesMap(
byteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: 1,
width: nil,
height: nil
).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale)
}

let firstImage = try #require(createIcon())
let secondImage = try #require(createIcon())

// The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe
// the same image must share a single instance.
#expect(firstImage === secondImage)
}

@Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon(imagePixelRatio: Double) -> UIImage? {
PlatformBitmapBytesMap(
byteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: imagePixelRatio,
width: nil,
height: nil
).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale)
}

let firstImage = try #require(createIcon(imagePixelRatio: 1))
let secondImage = try #require(createIcon(imagePixelRatio: 10))

#expect(firstImage !== secondImage)
#expect(firstImage.scale == 1.0)
#expect(secondImage.scale == 10.0)
}

/// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+.
/// On earlier versions, createIcon returns nil for PinConfig, which is expected behavior.
@Test func extractIconFromPinConfigWithGlyphColor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import CryptoKit
import Flutter
import GoogleMaps
import UIKit
Expand Down Expand Up @@ -63,6 +64,10 @@ extension PlatformBitmap {
}
}
case let bitmap as PlatformBitmapBytesMap:
let cacheKey = bitmap.iconCacheKey(screenScale: screenScale)
if let cachedIcon = bytesMapIconCache.object(forKey: cacheKey) {
return cachedIcon
}
let bytes = bitmap.byteData
image = UIImage(data: bytes.data, scale: screenScale)
if let currentImage = image {
Expand All @@ -81,6 +86,9 @@ extension PlatformBitmap {
image = UIImage(data: bytes.data)
}
}
if let icon = image {
bytesMapIconCache.setObject(icon, forKey: cacheKey)
}
case let bitmap as PlatformBitmapPinConfig:
let options = GMSPinImageOptions()
if let backgroundColor = bitmap.backgroundColor {
Expand Down Expand Up @@ -119,6 +127,31 @@ extension PlatformBitmap {
}
}

/// Caches the icons created from `PlatformBitmapBytesMap` bitmaps, keyed by everything that
/// affects the resulting image.
///
/// The Maps SDK allocates marker texture space per `UIImage` instance instead of per image content,
/// so creating a new `UIImage` for every marker exhausts the SDK's texture atlases ("Reached the
/// max number of texture atlases, can not allocate more."), after which markers are drawn with the
/// contents of other markers. Sharing one instance between identical bitmaps avoids that, and is
/// safe because `UIImage` is immutable.
///
/// `NSCache` is thread-safe, and releases its contents when the system is under memory pressure.
private let bytesMapIconCache = NSCache<NSString, UIImage>()

extension PlatformBitmapBytesMap {
/// Returns a key that covers every input of the icon created from this bitmap, so that bitmaps
/// that would produce different images never share one.
fileprivate func iconCacheKey(screenScale: CGFloat) -> NSString {
let contentHash = Data(SHA256.hash(data: byteData.data)).base64EncodedString()
let widthKey = width?.description ?? "nil"
let heightKey = height?.description ?? "nil"
return
"\(contentHash)|\(bitmapScaling.rawValue)|\(imagePixelRatio)|\(widthKey)|\(heightKey)|\(screenScale)"
as NSString
}
}

/// Creates a scaled version of the provided UIImage based on a specified scale factor.
///
/// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: google_maps_flutter_ios_sdk10
description: iOS implementation of the google_maps_flutter plugin using Google Maps SDK 10.
repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios_sdk10
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22
version: 2.18.13
version: 2.18.14

environment:
sdk: ^3.10.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.18.15

* Fixes corrupted and missing marker icons on maps with many markers created from
`BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same
bytes and scaling.

## 2.18.14

* Adopts new Pigeon async Swift support.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,50 @@ import Testing
#expect(resultImage?.size.height == 1.0)
}

@Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon() -> UIImage? {
PlatformBitmapBytesMap(
byteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: 1,
width: nil,
height: nil
).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale)
}

let firstImage = try #require(createIcon())
let secondImage = try #require(createIcon())

// The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe
// the same image must share a single instance.
#expect(firstImage === secondImage)
}

@Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws {
let pngData = try #require(createOnePixelImage().pngData())
let screenScale: CGFloat = 3.0

func createIcon(imagePixelRatio: Double) -> UIImage? {
PlatformBitmapBytesMap(
byteData: FlutterStandardTypedData(bytes: pngData),
bitmapScaling: .auto,
imagePixelRatio: imagePixelRatio,
width: nil,
height: nil
).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale)
}

let firstImage = try #require(createIcon(imagePixelRatio: 1))
let secondImage = try #require(createIcon(imagePixelRatio: 10))

#expect(firstImage !== secondImage)
#expect(firstImage.scale == 1.0)
#expect(secondImage.scale == 10.0)
}

/// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+.
/// On earlier versions, createIcon returns nil for PinConfig, which is expected behavior.
@Test func extractIconFromPinConfigWithGlyphColor() {
Expand Down
Loading
Loading