mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
* Automated update of relay data - Sun Sep 21 06:26:33 UTC 2025
* chore(l10n): add empty string catalogs
* chore(l10n): populate string catalogs from legacy resources
* test(l10n): add catalog guardrail suite
* chore(l10n): remove legacy localization files
* fix: Add localization resources to Package.swift targets
- Add .process("Localization") to bitchat target resources
- Add .process("Localization") to bitchatTests target resources
- Resolves Bundle.module resource loading for localization files
- Enables proper localization testing in Swift Package Manager builds
* feat: Add Korean localization and convert to UTF-8 format
Korean Language Support:
- Add complete Korean (ko) localization with 191 strings from PR #686
- Include all app strings: UI, features, system messages, alerts
- Include all share extension strings: status messages, errors
- Verified 100% translation coverage for Korean locale
UTF-8 Format Conversion:
- Convert 23,047 Unicode escape sequences to readable UTF-8 characters
- Transform \u sequences (e.g. \u0625\u063a\u0644\u0627\u0642) to native text (إغلاق)
- Improve maintainability across all 15 supported locales
- Preserve all existing translations while enhancing readability
Locales supported: en, ar, de, es, fr, he, id, it, ja, ko, ne, pt-BR, ru, uk, zh-Hans
* test: Enhance dynamic localization test framework
Dynamic Test Framework:
- Replace hardcoded locale tests with data-driven approach
- Add testLocalizationExpectedValues() for dynamic locale validation
- Add testConfiguredLocalesCompleteness() for coverage verification
- Tests now read configuration from PrimaryLocalizationKeys.json
Expanded Test Coverage:
- Increase from 14 to 33 key validations (135% increase)
- Add critical UI strings: common actions, app info, security, sharing
- Cover 364 total string validations across 15 locales
- Include Korean validation with native Korean expected values
Test Categories Added:
- Common UI: cancel, close, copy actions
- App Info: encryption, offline features, app name
- Bluetooth: permission and settings alerts
- Security: verification badges and actions
- Share Extension: all status and error messages
- Content Actions: accessibility and user actions
Maintains 100% test success rate across all supported locales.
* fix: Convert plural strings to correct xcstrings format
Three plural strings (content.accessibility.people_count,
location_channels.row_title, location_notes.header) were using
incorrect format causing runtime String(format:) errors.
Migrated from stringUnit.variations structure to proper
substitutions.variations format across all 14 languages.
* refactor(l10n): migrate to native String(localized:) APIs
Remove L10n.string wrapper in favor of Swift's native localization APIs.
Migrate 100+ localization call sites to use String(localized:) and String(localized:defaultValue:) with string interpolation.
- Update catalog to use interpolation syntax (\(var)) instead of format specifiers (%@)
- Migrate simple strings to String(localized:)
- Migrate strings with arguments to String(localized:defaultValue:) with interpolation
- Keep format strings for plural substitutions (String(format:locale:))
- Remove bitchat/Utils/Localization.swift
Net result: -407 insertions, +130 deletions across 15 files
* fix(l10n): correct interpolation to use format strings
Interpolation in String(localized:defaultValue:) doesn't work as expected -
the interpolation happens at the call site before localization lookup.
Convert dynamic strings to use String(format:String(localized:),args) pattern:
- Update catalog entries from \(var) syntax to %@ placeholders
- Wrap String(localized:) calls with String(format:locale:) for dynamic values
- Affects 17 strings across 6 files
This fixes UI showing literal "\(geohash)" text instead of actual values.
* chore(l10n): remove invalid catalog entries
Remove auto-extracted literal strings (@, #, ✔︎, @%@, bitchat/) that were
generated without proper localization structure. These caused test
decoding failures.
* fix(l10n): remove unused auto-extracted format string
Remove '%@/%@' key that was auto-extracted by Xcode but never used.
This key only existed in English causing locale parity test failures
across all 13 other languages.
Fixes locale parity tests - all 8 localization tests now pass with
only expected failures (incomplete translations in some locales).
* fix(l10n): copy format string to all locales for 100% completion
Add %@/%@ format string to all 14 non-English locales. Format strings
are locale-independent so using the same value everywhere is correct.
This brings all locales to 100% completion (189/189 strings) to prevent
Xcode from reporting incomplete translations when building.
* fix(l10n): prevent auto-extraction of UI literals
Use Text(verbatim:) for non-localizable UI elements:
- App branding ("bitchat/")
- Symbols (@, #, ✔︎)
- Dynamic usernames (@username)
- Count ratios (reached/total)
This prevents Xcode from auto-extracting these literals into the
String Catalog when building through Xcode GUI, which was causing
locales to show 96% completion instead of 100%.
* chore(l10n): remove auto-extracted UI literal entries
Delete 5 auto-extracted keys from catalog that are now using Text(verbatim:):
- @, #, ✔︎, %@, %@/%@
These were showing as stale/incomplete in Xcode causing 97% completion.
All locales now at 100% (188/188 strings).
* fix(l10n): prevent AttributedString from extracting @ symbol
Use string interpolation "\\(at)" instead of literal "@" in
AttributedString to prevent Xcode from auto-extracting it to the
String Catalog during build.
This was the last string causing locales to show 99% instead of 100%.
* fix(l10n): add %@ as non-translatable key in all locales
Mark %@ as non-translatable and add to all 15 locales with same value.
This prevents Xcode from showing incomplete translations when it
auto-extracts this format specifier during GUI builds.
All locales remain at 100% (189/189 strings).
* refactor: move Localizable.xcstrings to bitchat root
Move bitchat/Localization/Localizable.xcstrings to bitchat/ (after LaunchScreen)
and remove empty Localization directory.
* fix(test): update catalog path in localization tests
Update test paths from bitchat/Localization/Localizable.xcstrings to
bitchat/Localizable.xcstrings after moving the file.
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
180 lines
7.7 KiB
Swift
180 lines
7.7 KiB
Swift
//
|
|
// ShareViewController.swift
|
|
// bitchatShareExtension
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
/// Modern share extension using UIKit + UTTypes.
|
|
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
|
final class ShareViewController: UIViewController {
|
|
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
|
private static let groupID = "group.chat.bitchat"
|
|
|
|
private enum Strings {
|
|
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
|
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
|
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
|
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
|
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
|
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
|
}
|
|
|
|
private let statusLabel: UILabel = {
|
|
let l = UILabel()
|
|
l.translatesAutoresizingMaskIntoConstraints = false
|
|
l.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
l.textAlignment = .center
|
|
l.numberOfLines = 0
|
|
l.textColor = .label
|
|
return l
|
|
}()
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemBackground
|
|
view.addSubview(statusLabel)
|
|
NSLayoutConstraint.activate([
|
|
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
|
])
|
|
DispatchQueue.global().async {
|
|
self.processShare()
|
|
}
|
|
}
|
|
|
|
// MARK: - Processing
|
|
private func processShare() {
|
|
guard let ctx = self.extensionContext,
|
|
let item = ctx.inputItems.first as? NSExtensionItem else {
|
|
finishWithMessage(Strings.nothingToShare)
|
|
return
|
|
}
|
|
|
|
// Try content from attributed text first (Safari often passes URL here)
|
|
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
|
|
saveAndFinish(url: url, title: item.attributedTitle?.string)
|
|
return
|
|
}
|
|
|
|
// Scan attachments for URL/text
|
|
let providers = item.attachments ?? []
|
|
if providers.isEmpty {
|
|
// Fallback: use attributed title as plain text
|
|
if let title = item.attributedTitle?.string, !title.isEmpty {
|
|
saveAndFinish(text: title)
|
|
} else {
|
|
finishWithMessage(Strings.noShareableContent)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Load URL or text asynchronously
|
|
loadFirstURL(from: providers) { [weak self] url in
|
|
guard let self = self else { return }
|
|
if let url = url {
|
|
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
|
|
} else {
|
|
self.loadFirstPlainText(from: providers) { text in
|
|
if let t = text, !t.isEmpty {
|
|
// Treat as URL if parseable http(s), else plain text
|
|
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
|
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
|
} else {
|
|
self.saveAndFinish(text: t)
|
|
}
|
|
} else {
|
|
self.finishWithMessage(Strings.noShareableContent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detectURL(in text: String) -> URL? {
|
|
guard !text.isEmpty else { return nil }
|
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
let range = NSRange(location: 0, length: (text as NSString).length)
|
|
let match = detector?.matches(in: text, options: [], range: range).first
|
|
return match?.url
|
|
}
|
|
|
|
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
|
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
|
let grp = DispatchGroup()
|
|
var found: URL?
|
|
|
|
for p in providers where found == nil {
|
|
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
|
|
grp.enter()
|
|
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
|
defer { grp.leave() }
|
|
if let u = item as? URL { found = u; return }
|
|
if let s = item as? String, let u = URL(string: s) { found = u; return }
|
|
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
|
|
}
|
|
break
|
|
}
|
|
}
|
|
grp.notify(queue: .main) { completion(found) }
|
|
}
|
|
|
|
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
|
let id = UTType.plainText.identifier
|
|
let grp = DispatchGroup()
|
|
var text: String?
|
|
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
|
|
grp.enter()
|
|
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
|
defer { grp.leave() }
|
|
if let s = item as? String { text = s }
|
|
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
|
|
}
|
|
break
|
|
}
|
|
grp.notify(queue: .main) { completion(text) }
|
|
}
|
|
|
|
// MARK: - Save + Finish
|
|
private func saveAndFinish(url: URL, title: String?) {
|
|
let payload: [String: String] = [
|
|
"url": url.absoluteString,
|
|
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
|
]
|
|
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
|
let s = String(data: json, encoding: .utf8) {
|
|
saveToSharedDefaults(content: s, type: "url")
|
|
finishWithMessage(Strings.sharedLinkConfirmation)
|
|
} else {
|
|
finishWithMessage(Strings.failedToEncode)
|
|
}
|
|
}
|
|
|
|
private func saveAndFinish(text: String) {
|
|
saveToSharedDefaults(content: text, type: "text")
|
|
finishWithMessage(Strings.sharedTextConfirmation)
|
|
}
|
|
|
|
private func saveToSharedDefaults(content: String, type: String) {
|
|
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
|
userDefaults.set(content, forKey: "sharedContent")
|
|
userDefaults.set(type, forKey: "sharedContentType")
|
|
userDefaults.set(Date(), forKey: "sharedContentDate")
|
|
// No need to force synchronize; the system persists changes
|
|
}
|
|
|
|
private func finishWithMessage(_ msg: String) {
|
|
statusLabel.text = msg
|
|
// Complete shortly after showing status
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|
|
}
|