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
4 changes: 4 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,7 @@
88D6E93F254CF712003142D9 /* group_call_leave.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 88D6E93D254CF712003142D9 /* group_call_leave.aiff */; };
88D6E940254CF712003142D9 /* group_call_join.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 88D6E93E254CF712003142D9 /* group_call_join.aiff */; };
88D6E94325535D49003142D9 /* CVTextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88D6E94125535482003142D9 /* CVTextTest.swift */; };
A1B2C3D4E5F60718293A4B5D /* CVWallpaperBlurViewTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* CVWallpaperBlurViewTest.swift */; };
88DBDFB9263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBDFB8263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift */; };
88DBDFBB2638FFBD00C2101C /* audio-played-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = 88DBDFBA2638FFBC00C2101C /* audio-played-dot.json */; };
88DF819528E1130900F8BA80 /* SignalProxy+RelayServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DF819428E1130900F8BA80 /* SignalProxy+RelayServer.swift */; };
Expand Down Expand Up @@ -6145,6 +6146,7 @@
88D6E93D254CF712003142D9 /* group_call_leave.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = group_call_leave.aiff; sourceTree = "<group>"; };
88D6E93E254CF712003142D9 /* group_call_join.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = group_call_join.aiff; sourceTree = "<group>"; };
88D6E94125535482003142D9 /* CVTextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVTextTest.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* CVWallpaperBlurViewTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVWallpaperBlurViewTest.swift; sourceTree = "<group>"; };
88D7BA9D266809F50088D1C2 /* CallMessageRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageRelay.swift; sourceTree = "<group>"; };
88DBDFB8263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDisappearingMessageTimerInteraction.swift; sourceTree = "<group>"; };
88DBDFBA2638FFBC00C2101C /* audio-played-dot.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "audio-played-dot.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -9716,6 +9718,7 @@
4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */,
3463532E256EA525003C5428 /* ConversationViewTest.swift */,
88D6E94125535482003142D9 /* CVTextTest.swift */,
A1B2C3D4E5F60718293A4B5C /* CVWallpaperBlurViewTest.swift */,
F96B66B22912B8B7004FFFAA /* DonateViewControllerTest.swift */,
F99D2C8A2926F0DD00748CCB /* DonationPaymentDetailsViewControllerTest.swift */,
45C845AE291467F7005F6EA5 /* JournalingOrderedDictionaryTests.swift */,
Expand Down Expand Up @@ -19412,6 +19415,7 @@
4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */,
34635330256EA52A003C5428 /* ConversationViewTest.swift in Sources */,
88D6E94325535D49003142D9 /* CVTextTest.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* CVWallpaperBlurViewTest.swift in Sources */,
F93999F628C81F2100E34899 /* DataMessagePaddingTests.swift in Sources */,
3494BBE026E66FC30079B11B /* DateUtilTest.swift in Sources */,
C17A54962D7B3D9E00E1D267 /* DeviceProvisioningURLTest.swift in Sources */,
Expand Down
15 changes: 15 additions & 0 deletions Signal/ConversationView/CellViews/CVWallpaperBlurView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,25 @@ public class CVWallpaperBlurView: ManualLayoutViewWithLayer, CVDimmableView {
}
}

/// The blurred wallpaper image only spans the reference view, so a bubble
/// that extends beyond it (e.g. a very tall message in the context menu
/// preview) would be left unfilled where the image doesn't reach.
static func needsFallbackBackground(bubbleBounds: CGRect, imageFrame: CGRect) -> Bool {
return !imageFrame.contains(bubbleBounds)
}

public func applyLayout() {
guard bounds.size.isNonEmpty else { return }

UIView.performWithoutAnimation {
if let state, !isPreview, !isReduceTransparencyMode {
// The image is opaque, so this is only visible where it doesn't reach.
backgroundColor = Self.needsFallbackBackground(
bubbleBounds: bounds,
imageFrame: imageViewFrame,
) ? (state.averageColor ?? Theme.backgroundColor) : nil
}

imageView.frame = imageViewFrame
imageViewMaskLayer.frame = imageView.layer.bounds
strokeLayer.frame = layer.bounds
Expand Down
104 changes: 104 additions & 0 deletions Signal/test/ViewControllers/CVWallpaperBlurViewTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Testing
import UIKit

@testable import Signal
@testable import SignalUI

struct CVWallpaperBlurViewTest {

// MARK: - Average color

private func rgba(_ color: UIColor) -> (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
}

private func makeImage(size: CGSize, draw: (CGContext) -> Void) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
format.opaque = true
return UIGraphicsImageRenderer(size: size, format: format).image { draw($0.cgContext) }
}

@Test
func testAverageColorOfSolidImageIsThatColor() throws {
let image = makeImage(size: CGSize(width: 40, height: 40)) { context in
context.setFillColor(UIColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1).cgColor)
context.fill(CGRect(x: 0, y: 0, width: 40, height: 40))
}

let color = try #require(image.averageColor())
let (r, g, b, a) = rgba(color)

#expect(abs(r - 0.2) < 0.02)
#expect(abs(g - 0.4) < 0.02)
#expect(abs(b - 0.6) < 0.02)
#expect(abs(a - 1) < 0.01)
}

@Test
func testAverageColorOfHalfBlackHalfWhiteImageIsMidGray() throws {
let image = makeImage(size: CGSize(width: 40, height: 40)) { context in
context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(x: 0, y: 0, width: 40, height: 40))
context.setFillColor(UIColor.black.cgColor)
context.fill(CGRect(x: 0, y: 0, width: 40, height: 20))
}

let color = try #require(image.averageColor())
let (r, g, b, _) = rgba(color)

#expect(abs(r - 0.5) < 0.05)
#expect(abs(g - 0.5) < 0.05)
#expect(abs(b - 0.5) < 0.05)
}

// MARK: - Fallback background

@Test
func testBubbleFullyCoveredByImageNeedsNoFallback() {
let bubbleBounds = CGRect(x: 0, y: 0, width: 326, height: 372)
let imageFrame = CGRect(x: -16, y: -351, width: 402, height: 874)

#expect(!CVWallpaperBlurView.needsFallbackBackground(bubbleBounds: bubbleBounds, imageFrame: imageFrame))
}

@Test
func testBubbleExactlyMatchingImageNeedsNoFallback() {
let frame = CGRect(x: 0, y: 0, width: 402, height: 874)

#expect(!CVWallpaperBlurView.needsFallbackBackground(bubbleBounds: frame, imageFrame: frame))
}

/// A very tall message is taller than the screen-sized wallpaper image, so the
/// bottom of the bubble has no fill. This is the bug from issue #6356.
@Test
func testBubbleTallerThanImageNeedsFallback() {
let bubbleBounds = CGRect(x: 0, y: 0, width: 326, height: 1820)
let imageFrame = CGRect(x: -16, y: -351, width: 402, height: 874)

#expect(CVWallpaperBlurView.needsFallbackBackground(bubbleBounds: bubbleBounds, imageFrame: imageFrame))
}

@Test
func testBubbleStartingAboveImageNeedsFallback() {
let bubbleBounds = CGRect(x: 0, y: 0, width: 326, height: 372)
let imageFrame = CGRect(x: -16, y: 100, width: 402, height: 874)

#expect(CVWallpaperBlurView.needsFallbackBackground(bubbleBounds: bubbleBounds, imageFrame: imageFrame))
}

@Test
func testBubbleWiderThanImageNeedsFallback() {
let bubbleBounds = CGRect(x: 0, y: 0, width: 326, height: 372)
let imageFrame = CGRect(x: 0, y: -100, width: 300, height: 874)

#expect(CVWallpaperBlurView.needsFallbackBackground(bubbleBounds: bubbleBounds, imageFrame: imageFrame))
}
}
35 changes: 35 additions & 0 deletions SignalUI/UIKitExtensions/UIKit+Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ public extension UIImageView {
// MARK: -

public extension UIImage {
/// The average color of all the image's pixels, found by drawing the image
/// into a single pixel.
func averageColor() -> UIColor? {
guard let cgImage else { return nil }

var pixel = [UInt8](repeating: 0, count: 4)
let drewPixel = pixel.withUnsafeMutableBytes { buffer -> Bool in
guard
let context = CGContext(
data: buffer.baseAddress,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue,
)
else { return false }
context.interpolationQuality = .high
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: 1, height: 1))
return true
}
guard drewPixel else { return nil }

let alpha = CGFloat(pixel[3]) / 255
guard alpha > 0 else { return nil }
// The context stores premultiplied components.
return UIColor(
red: CGFloat(pixel[0]) / 255 / alpha,
green: CGFloat(pixel[1]) / 255 / alpha,
blue: CGFloat(pixel[2]) / 255 / alpha,
alpha: alpha,
)
}

/// Redraw the image into a new image, with an added background color, and inset the
/// original image by the provided insets.
func withBackgroundColor(_ color: UIColor, insets: UIEdgeInsets = .zero) -> UIImage? {
Expand Down
3 changes: 3 additions & 0 deletions SignalUI/Wallpapers/Wallpaper+SignalUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ public class WallpaperBlurState: NSObject {
public let referenceView: UIView
fileprivate let token: WallpaperBlurToken

/// The average color of `image`, for filling areas the image doesn't reach.
public private(set) lazy var averageColor: UIColor? = image.averageColor()

private static let idCounter = AtomicUInt(0, lock: .sharedGlobal)
public let id: UInt = WallpaperBlurState.idCounter.increment()

Expand Down