Change license to AGPL
This commit:
- Updates the `LICENSE` file
- Start every file with something like:
// Copyright YEAR_FIRST_PUBLISHED Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
---
First, I removed existing license headers with this Ruby 3.1.2 script:
require 'set'
EXTENSIONS_TO_CHECK = Set['.h', '.hpp', '.cpp', '.m', '.mm', '.pch', '.swift']
same = 0
different = 0
all_files = `git ls-files`.lines.map { |line| line.strip }
all_files.each do |relative_path|
if relative_path == 'Pods'
next
end
unless EXTENSIONS_TO_CHECK.include? File.extname(relative_path)
next
end
path = File.expand_path(relative_path)
contents = File.read(path)
new_contents = contents.sub(/\/\/\n\/\/ Copyright .*\n\/\/\n\n/, '')
if contents == new_contents
same += 1
else
different += 1
end
File.write(path, new_contents)
end
puts "updated #{different} file(s), left #{same} untouched"
I'm sure this script could be improved, but it worked well enough.
Then, I created `Scripts/lint/lint-license-headers` and ran it to auto-
fix a lot of files. This changed the mode of some files, but I think
that's actually desirable. For example,
`SignalServiceKit/src/Util/AppContext.m` previously had a mode of
`0755/-rwxr-xr-x`, and it's now `0644/-rw-r--r--`.
Then I fixed some stragglers and updated the precommit script.
See [a similar change in the Desktop app][0].
[0]: 8bfaf598af
396 lines
13 KiB
Swift
396 lines
13 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// Like TSGroupModel, TSGroupModelV2 is intended to be immutable.
|
|
//
|
|
// NOTE: This class is tightly coupled to TSGroupModelBuilder.
|
|
// If you modify this class - especially if you
|
|
// add any new properties - make sure to update
|
|
// TSGroupModelBuilder.
|
|
@objc
|
|
public class TSGroupModelV2: TSGroupModel {
|
|
|
|
// These properties TSGroupModel, TSGroupModelV2 is intended to be immutable.
|
|
@objc
|
|
var membership: GroupMembership = GroupMembership.empty
|
|
@objc
|
|
public var access: GroupAccess = .defaultForV2
|
|
@objc
|
|
public var secretParamsData: Data = Data()
|
|
@objc
|
|
public var revision: UInt32 = 0
|
|
@objc
|
|
public var avatarUrlPath: String?
|
|
@objc
|
|
public var inviteLinkPassword: Data?
|
|
@objc
|
|
public var isAnnouncementsOnly: Bool = false
|
|
// We sometimes create "placeholder" models to reflect
|
|
// groups that we don't have access to on the service.
|
|
@objc
|
|
public var isPlaceholderModel: Bool = false
|
|
@objc
|
|
public var wasJustMigrated: Bool = false
|
|
@objc
|
|
public var wasJustCreatedByLocalUser: Bool = false
|
|
@objc
|
|
public var didJustAddSelfViaGroupLink: Bool = false
|
|
@objc
|
|
public var droppedMembers = [SignalServiceAddress]()
|
|
@objc
|
|
public var descriptionText: String?
|
|
|
|
@objc
|
|
public required init(groupId: Data,
|
|
name: String?,
|
|
descriptionText: String?,
|
|
avatarData: Data?,
|
|
groupMembership: GroupMembership,
|
|
groupAccess: GroupAccess,
|
|
revision: UInt32,
|
|
secretParamsData: Data,
|
|
avatarUrlPath: String?,
|
|
inviteLinkPassword: Data?,
|
|
isAnnouncementsOnly: Bool,
|
|
isPlaceholderModel: Bool,
|
|
wasJustMigrated: Bool,
|
|
wasJustCreatedByLocalUser: Bool,
|
|
didJustAddSelfViaGroupLink: Bool,
|
|
addedByAddress: SignalServiceAddress?,
|
|
droppedMembers: [SignalServiceAddress]) {
|
|
assert(secretParamsData.count > 0)
|
|
|
|
self.descriptionText = descriptionText
|
|
self.membership = groupMembership
|
|
self.secretParamsData = secretParamsData
|
|
self.access = groupAccess
|
|
self.revision = revision
|
|
self.avatarUrlPath = avatarUrlPath
|
|
self.inviteLinkPassword = inviteLinkPassword
|
|
self.isAnnouncementsOnly = isAnnouncementsOnly
|
|
self.isPlaceholderModel = isPlaceholderModel
|
|
self.wasJustMigrated = wasJustMigrated
|
|
self.wasJustCreatedByLocalUser = wasJustCreatedByLocalUser
|
|
self.didJustAddSelfViaGroupLink = didJustAddSelfViaGroupLink
|
|
self.droppedMembers = droppedMembers
|
|
|
|
super.init(groupId: groupId,
|
|
name: name,
|
|
avatarData: avatarData,
|
|
members: [],
|
|
addedBy: addedByAddress)
|
|
}
|
|
|
|
// MARK: - MTLModel
|
|
|
|
@objc
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
}
|
|
|
|
@objc
|
|
public required init(dictionary dictionaryValue: [String: Any]!) throws {
|
|
try super.init(dictionary: dictionaryValue)
|
|
}
|
|
|
|
public override class func storageBehaviorForProperty(withKey propertyKey: String) -> MTLPropertyStorage {
|
|
if propertyKey == #keyPath(groupMembers) {
|
|
// This is included in groupMembership.
|
|
return MTLPropertyStorageNone
|
|
}
|
|
return super.storageBehaviorForProperty(withKey: propertyKey)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public override var groupsVersion: GroupsVersion {
|
|
return .V2
|
|
}
|
|
|
|
@objc
|
|
public override var groupMembership: GroupMembership {
|
|
return membership
|
|
}
|
|
|
|
@objc
|
|
public override var groupMembers: [SignalServiceAddress] {
|
|
return Array(groupMembership.fullMembers)
|
|
}
|
|
|
|
public override func isEqual(to model: TSGroupModel,
|
|
comparisonMode: TSGroupModelComparisonMode) -> Bool {
|
|
guard super.isEqual(to: model, comparisonMode: comparisonMode) else {
|
|
return false
|
|
}
|
|
guard let other = model as? TSGroupModelV2 else {
|
|
switch comparisonMode {
|
|
case .compareAll:
|
|
return false
|
|
case .userFacingOnly:
|
|
return descriptionText == nil
|
|
}
|
|
}
|
|
guard other.descriptionText == descriptionText else {
|
|
return false
|
|
}
|
|
guard other.membership == membership else {
|
|
return false
|
|
}
|
|
guard other.access == access else {
|
|
return false
|
|
}
|
|
guard other.secretParamsData == secretParamsData else {
|
|
return false
|
|
}
|
|
guard comparisonMode != .compareAll || other.revision == revision else {
|
|
return false
|
|
}
|
|
guard other.avatarUrlPath == avatarUrlPath else {
|
|
return false
|
|
}
|
|
guard other.inviteLinkPassword == inviteLinkPassword else {
|
|
return false
|
|
}
|
|
guard other.isAnnouncementsOnly == isAnnouncementsOnly else {
|
|
return false
|
|
}
|
|
guard other.droppedMembers.stableSort() == droppedMembers.stableSort() else {
|
|
return false
|
|
}
|
|
// Ignore transient properties:
|
|
//
|
|
// * isPlaceholderModel
|
|
// * wasJustMigrated
|
|
// * wasJustCreatedByLocalUser
|
|
// * didJustAddSelfViaGroupLink
|
|
return true
|
|
}
|
|
|
|
@objc
|
|
public override var debugDescription: String {
|
|
var result = "["
|
|
result += "groupId: \(groupId.hexadecimalString),\n"
|
|
result += "groupsVersion: \(groupsVersion),\n"
|
|
result += "groupName: \(String(describing: groupName)),\n"
|
|
result += "avatarHash: \(String(describing: avatarHash)),\n"
|
|
result += "membership: \(groupMembership.debugDescription),\n"
|
|
result += "access: \(access.debugDescription),\n"
|
|
result += "secretParamsData: \(secretParamsData.hexadecimalString.prefix(32)),\n"
|
|
result += "revision: \(revision),\n"
|
|
result += "avatarUrlPath: \(String(describing: avatarUrlPath)),\n"
|
|
result += "inviteLinkPassword: \(inviteLinkPassword?.hexadecimalString ?? "None"),\n"
|
|
result += "isAnnouncementsOnly: \(isAnnouncementsOnly),\n"
|
|
result += "addedByAddress: \(addedByAddress?.debugDescription ?? "None"),\n"
|
|
result += "isPlaceholderModel: \(isPlaceholderModel),\n"
|
|
result += "wasJustMigrated: \(wasJustMigrated),\n"
|
|
result += "wasJustCreatedByLocalUser: \(wasJustCreatedByLocalUser),\n"
|
|
result += "didJustAddSelfViaGroupLink: \(didJustAddSelfViaGroupLink),\n"
|
|
result += "droppedMembers: \(droppedMembers),\n"
|
|
result += "descriptionText: \(String(describing: descriptionText)),\n"
|
|
result += "]"
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public extension TSGroupModelV2 {
|
|
var groupInviteLinkMode: GroupsV2LinkMode {
|
|
guard let inviteLinkPassword = inviteLinkPassword,
|
|
!inviteLinkPassword.isEmpty else {
|
|
return .disabled
|
|
}
|
|
|
|
switch access.addFromInviteLink {
|
|
case .any:
|
|
return .enabledWithoutApproval
|
|
case .administrator:
|
|
return .enabledWithApproval
|
|
default:
|
|
return .disabled
|
|
}
|
|
}
|
|
|
|
var isGroupInviteLinkEnabled: Bool {
|
|
if let inviteLinkPassword = inviteLinkPassword,
|
|
!inviteLinkPassword.isEmpty,
|
|
access.canJoinFromInviteLink {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public extension TSGroupModel {
|
|
var isPlaceholder: Bool {
|
|
guard let groupModelV2 = self as? TSGroupModelV2 else {
|
|
return false
|
|
}
|
|
return groupModelV2.isPlaceholderModel
|
|
}
|
|
|
|
var wasJustMigratedToV2: Bool {
|
|
guard let groupModelV2 = self as? TSGroupModelV2 else {
|
|
return false
|
|
}
|
|
return groupModelV2.wasJustMigrated
|
|
}
|
|
|
|
var wasJustCreatedByLocalUserV2: Bool {
|
|
guard let groupModelV2 = self as? TSGroupModelV2 else {
|
|
return false
|
|
}
|
|
return groupModelV2.wasJustCreatedByLocalUser
|
|
}
|
|
|
|
var didJustAddSelfViaGroupLinkV2: Bool {
|
|
guard let groupModelV2 = self as? TSGroupModelV2 else {
|
|
return false
|
|
}
|
|
return groupModelV2.didJustAddSelfViaGroupLink
|
|
}
|
|
|
|
var getDroppedMembers: [SignalServiceAddress] {
|
|
guard let groupModelV2 = self as? TSGroupModelV2 else {
|
|
return []
|
|
}
|
|
return groupModelV2.droppedMembers
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public extension TSGroupModel {
|
|
private static let appSharedDataDirectory = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
|
|
static let avatarsDirectory = URL(fileURLWithPath: "GroupAvatars", isDirectory: true, relativeTo: appSharedDataDirectory)
|
|
@nonobjc
|
|
private static let avatarsCache = LRUCache<String, Data>(maxSize: 16, nseMaxSize: 0)
|
|
|
|
func attemptToMigrateLegacyAvatarDataToDisk() throws {
|
|
guard let legacyAvatarData = legacyAvatarData, !legacyAvatarData.isEmpty else {
|
|
self.legacyAvatarData = nil
|
|
return
|
|
}
|
|
|
|
guard Self.isValidGroupAvatarData(legacyAvatarData) else {
|
|
owsFailDebug("Invalid legacy avatar data. Removing it completely")
|
|
self.avatarHash = nil
|
|
self.legacyAvatarData = nil
|
|
return
|
|
}
|
|
|
|
try persistAvatarData(legacyAvatarData)
|
|
self.legacyAvatarData = nil
|
|
}
|
|
|
|
func persistAvatarData(_ data: Data) throws {
|
|
guard !data.isEmpty else {
|
|
self.avatarHash = nil
|
|
return
|
|
}
|
|
|
|
guard Self.isValidGroupAvatarData(data) else {
|
|
throw OWSAssertionError("Invalid group avatar")
|
|
}
|
|
|
|
let hash = try Self.hash(forAvatarData: data)
|
|
|
|
OWSFileSystem.ensureDirectoryExists(Self.avatarsDirectory.path)
|
|
|
|
let filePath = Self.avatarFilePath(forHash: hash)
|
|
guard !OWSFileSystem.fileOrFolderExists(url: filePath) else {
|
|
// Avatar is already persisted.
|
|
self.avatarHash = hash
|
|
return
|
|
}
|
|
|
|
try data.write(to: Self.avatarFilePath(forHash: hash))
|
|
Self.avatarsCache.set(key: hash, value: data)
|
|
|
|
// Note: Old avatars are explicitly not cleaned up from the file
|
|
// system at this point, as multiple instances of a group model
|
|
// may be floating around referencing different versions of
|
|
// the avatar. We only purge old avatars from the file system
|
|
// when orphan data cleaner deems it safe to do so.
|
|
|
|
self.avatarHash = hash
|
|
}
|
|
|
|
class func hash(forAvatarData avatarData: Data) throws -> String {
|
|
guard let digest = Cryptography.computeSHA256Digest(avatarData) else {
|
|
throw OWSAssertionError("Unexpectedly failed to calculate avatar digest")
|
|
}
|
|
|
|
return digest.hexadecimalString
|
|
}
|
|
|
|
var avatarData: Data? {
|
|
if let avatarHash = avatarHash, let cachedData = Self.avatarsCache.object(forKey: avatarHash) {
|
|
return cachedData
|
|
}
|
|
|
|
guard let fileName = avatarFileName else { return nil }
|
|
let filePath = URL(fileURLWithPath: fileName, relativeTo: Self.avatarsDirectory)
|
|
|
|
let avatarData: Data
|
|
do {
|
|
avatarData = try Data(contentsOf: filePath)
|
|
} catch {
|
|
owsFailDebug("Failed to read group avatar data \(error)")
|
|
return nil
|
|
}
|
|
|
|
guard avatarData.ows_isValidImage else {
|
|
owsFailDebug("Invalid group avatar data.")
|
|
return nil
|
|
}
|
|
|
|
return avatarData
|
|
}
|
|
|
|
var avatarImage: UIImage? {
|
|
guard let avatarData = avatarData else {
|
|
return nil
|
|
}
|
|
return UIImage(data: avatarData)
|
|
}
|
|
|
|
var avatarFileName: String? {
|
|
guard let hash = avatarHash else { return nil }
|
|
return Self.avatarFileName(forHash: hash)
|
|
}
|
|
|
|
static func avatarFileName(forHash hash: String) -> String {
|
|
// All group avatars are PNGs, use the appropriate file extension.
|
|
return "\(hash).png"
|
|
}
|
|
|
|
static func avatarFilePath(forHash hash: String) -> URL {
|
|
URL(fileURLWithPath: avatarFileName(forHash: hash), relativeTo: avatarsDirectory)
|
|
}
|
|
|
|
class func allGroupAvatarFilePaths(transaction: SDSAnyReadTransaction) throws -> Set<String> {
|
|
let cursor = TSThread.grdbFetchCursor(
|
|
sql: "SELECT * FROM \(ThreadRecord.databaseTableName) WHERE \(threadColumn: .recordType) = \(SDSRecordType.groupThread.rawValue)",
|
|
transaction: transaction.unwrapGrdbRead
|
|
)
|
|
|
|
var filePaths = Set<String>()
|
|
|
|
while let thread = try cursor.next() as? TSGroupThread {
|
|
guard let avatarHash = thread.groupModel.avatarHash else { continue }
|
|
filePaths.insert(avatarFilePath(forHash: avatarHash).path)
|
|
}
|
|
|
|
return filePaths
|
|
}
|
|
}
|