From 0225f436bd0ae3cfac488eb3dcc2d83523382d60 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 4 Jul 2023 17:09:50 +1000 Subject: [PATCH] Podfile tweaks to speed up sim builds, unit tests & minor bug fix Added a patch to the Podfile to avoid rsync'ing and signing WebRTC-lib for simulator builds shaving off 10+ seconds of build time per target due to the sheer size of the WebRTC debug framework Added some basic unit tests to validate the current search behaviour Fixed some buggy search behaviours --- Podfile | 10 + Podfile.lock | 2 +- Scripts/skip_web_rtc_re_rsync.patch | 12 + Session.xcodeproj/project.pbxproj | 14 +- .../ConversationVC+Interaction.swift | 3 +- .../MessageRequestsViewController.swift | 1 + .../SessionThreadViewModel.swift | 49 +-- .../SessionThreadViewModelSpec.swift | 334 ++++++++++++++++++ ...eadDisappearingMessagesViewModelSpec.swift | 2 + .../ThreadSettingsViewModelSpec.swift | 2 + 10 files changed, 403 insertions(+), 26 deletions(-) create mode 100644 Scripts/skip_web_rtc_re_rsync.patch create mode 100644 SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift diff --git a/Podfile b/Podfile index 5747d4620..25f76e8e9 100644 --- a/Podfile +++ b/Podfile @@ -101,6 +101,7 @@ end # Actions to perform post-install post_install do |installer| set_minimum_deployment_target(installer) + avoid_rsync_webrtc_if_unchanged(installer) end def set_minimum_deployment_target(installer) @@ -110,3 +111,12 @@ def set_minimum_deployment_target(installer) end end end + +# This function patches the Cocoapods 'Embed Frameworks' script to avoid running rsync +# for the WebRTC-lib framework in simulator builds if it has already been copied over +# because due to the size it can take over 10 seconds to embed, and gets embeded in +# each target on every build regardless of whether there were changes, drastically +# increasing the length of the build +def avoid_rsync_webrtc_if_unchanged(installer) + system('find "./Pods/Target Support Files" -name "*-frameworks.sh" -exec patch -p0 -i ./Scripts/skip_web_rtc_re_rsync.patch {} \;') +end diff --git a/Podfile.lock b/Podfile.lock index 970a62e71..d44b761b9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -204,6 +204,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 68799237a4dc046f5ac25c573af03b559f5b10c4 +PODFILE CHECKSUM: dcca0c4ad69b14cbc2d6ba49f9d690b239828e6d COCOAPODS: 1.12.1 diff --git a/Scripts/skip_web_rtc_re_rsync.patch b/Scripts/skip_web_rtc_re_rsync.patch new file mode 100644 index 000000000..6b8232e50 --- /dev/null +++ b/Scripts/skip_web_rtc_re_rsync.patch @@ -0,0 +1,12 @@ +@@ -41,0 +41,11 @@ ++ # Skip the rsync step for the WebRTC-lib in simulator builds ++ if [[ "$PLATFORM_NAME" == "iphonesimulator" ]] && [[ "$source" == *WebRTC.framework* ]]; then ++ if [[ -f "${source}/../already_rsynced.nonce" ]]; then ++ echo "Already rsynced WebRTC, skipping" ++ return 0 ++ fi ++ ++ echo "About to rsync a simulator WebRTC, creating nonce to prevent future rsyncing" ++ touch "${source}/../already_rsynced.nonce" ++ fi ++ diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 169bf35ab..7e18bd930 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -696,6 +696,7 @@ FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; @@ -1828,6 +1829,7 @@ FD716E692850327900C96BF4 /* EndCallMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndCallMode.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; + FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; @@ -3316,10 +3318,10 @@ B8DE1FB226C22F1F0079C9CE /* Calls */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, - C300A5F02554B08500555489 /* Sending & Receiving */, C352A2F325574B3300338F3E /* Jobs */, C3A7215C2558C0AC0043A11F /* File Server */, C3A721332558BDDF0043A11F /* Open Groups */, + C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* SessionUtil */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, @@ -3994,6 +3996,14 @@ path = Views; sourceTree = ""; }; + FD7692F52A53A2C7000E4B70 /* Shared Models */ = { + isa = PBXGroup; + children = ( + FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */, + ); + path = "Shared Models"; + sourceTree = ""; + }; FD7728A1284F0DF50018502F /* Message Handling */ = { isa = PBXGroup; children = ( @@ -4224,6 +4234,7 @@ FD3C906527E416A200CD579F /* Contacts */, FDC4389827BA001800C60D73 /* Open Groups */, FD3C906B27E43C2400CD579F /* Sending & Receiving */, + FD7692F52A53A2C7000E4B70 /* Shared Models */, FD3C906827E417B100CD579F /* Utilities */, FD8ECF802934385900C0D1BB /* LibSessionUtil */, ); @@ -6391,6 +6402,7 @@ FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 13704aadb..db534d40b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1979,8 +1979,7 @@ extension ConversationVC: self?.showInputAccessoryView() }) - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 + self.hideInputAccessoryView() Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view) self.present(actionSheet, animated: true) } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 42cb93344..6b91630da 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -106,6 +106,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal) result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside) + result.accessibilityIdentifier = "Clear all" return result }() diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 5fd4f3857..bee72d0d0 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -1112,19 +1112,22 @@ public extension SessionThreadViewModel { /// Step 2 - Separate any words outside of quotes /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) - return standardQuotes(searchTerm) - .split(separator: "\"") - .enumerated() - .flatMap { index, value -> [String] in - guard index % 2 == 1 else { - return String(value) - .split(separator: " ") - .map { "\"\(String($0))\"" } - } - - return ["\"\(value)\""] - } - .filter { !$0.isEmpty } + let normalisedTerm: String = standardQuotes(searchTerm) + + guard let regex = try? NSRegularExpression(pattern: "[^\\s\"']+|\"([^\"]*)\"") else { + // Fallback to removing the quotes and just splitting on spaces + return normalisedTerm + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + .map { "\"\($0)\"" } + .filter { !$0.isEmpty } + } + + return regex + .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) + .compactMap { Range($0.range, in: normalisedTerm) } + .map { normalisedTerm[$0].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } + .map { "\"\($0)\"" } } static func standardQuotes(_ term: String) -> String { @@ -1155,15 +1158,17 @@ public extension SessionThreadViewModel { /// There are cases where creating a pattern can fail, we want to try and recover from those cases /// by failling back to simpler patterns if needed - let maybePattern: FTS5Pattern? = (try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table)) - .defaulting( - to: (try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table)) - .defaulting(to: FTS5Pattern(matchingAnyTokenIn: fallbackTerm)) - ) - - guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern } - - return pattern + return try { + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { + return pattern + } + + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { + return pattern + } + + return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() + }() } static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { diff --git a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift new file mode 100644 index 000000000..df58e26c3 --- /dev/null +++ b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift @@ -0,0 +1,334 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Quick +import Nimble +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class SessionThreadViewModelSpec: QuickSpec { + public struct TestMessage: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "testMessage" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case body + } + + public let body: String + } + + // MARK: - Spec + + override func spec() { + describe("a SessionThreadViewModel") { + var mockStorage: Storage! + + beforeEach { + mockStorage = SynchronousStorage( + customWriter: try! DatabaseQueue() + ) + + mockStorage.write { db in + try db.create(table: TestMessage.self) { t in + t.column(.body, .text).notNull() + } + + try db.create(virtualTable: TestMessage.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: TestMessage.databaseTableName) + t.tokenizer = .porter(wrapping: .unicode61()) + + t.column(TestMessage.Columns.body.name) + } + } + } + + // MARK: - when processing a search term + context("when processing a search term") { + // MARK: -- correctly generates a safe search term + it("correctly generates a safe search term") { + expect(SessionThreadViewModel.searchSafeTerm("Test")).to(equal("\"Test\"")) + } + + // MARK: -- standardises odd quote characters + it("standardises odd quote characters") { + expect(SessionThreadViewModel.standardQuotes("\"")).to(equal("\"")) + expect(SessionThreadViewModel.standardQuotes("”")).to(equal("\"")) + expect(SessionThreadViewModel.standardQuotes("“")).to(equal("\"")) + } + + // MARK: -- splits on the space character + it("splits on the space character") { + expect(SessionThreadViewModel.searchTermParts("Test Message")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- surrounds each split term with quotes + it("surrounds each split term with quotes") { + expect(SessionThreadViewModel.searchTermParts("Test Message")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- keeps words within quotes together + it("keeps words within quotes together") { + expect(SessionThreadViewModel.searchTermParts("This \"is a Test\" Message")) + .to(equal([ + "\"This\"", + "\"is a Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" a Test Message")) + .to(equal([ + "\"This is\"", + "\"a\"", + "\"Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" \"a Test\" Message")) + .to(equal([ + "\"This is\"", + "\"a Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" a \"Test Message\"")) + .to(equal([ + "\"This is\"", + "\"a\"", + "\"Test Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\"\" a \"Test Message")) + .to(equal([ + "\"This is\"", + "\" a \"", + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- keeps words within weird quotes together + it("keeps words within weird quotes together") { + expect(SessionThreadViewModel.searchTermParts("This ”is a Test“ Message")) + .to(equal([ + "\"This\"", + "\"is a Test\"", + "\"Message\"" + ])) + } + + // MARK: -- removes extra whitespace + it("removes extra whitespace") { + expect(SessionThreadViewModel.searchTermParts(" Test Message ")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + } + + // MARK: - when searching + context("when searching") { + beforeEach { + mockStorage.write { db in + try TestMessage(body: "Test").insert(db) + try TestMessage(body: "Test123").insert(db) + try TestMessage(body: "Test234").insert(db) + try TestMessage(body: "Test Test123").insert(db) + try TestMessage(body: "Test Test123 Test234").insert(db) + try TestMessage(body: "Test Test234").insert(db) + try TestMessage(body: "Test Test234 Test123").insert(db) + try TestMessage(body: "This is a Test Message").insert(db) + try TestMessage(body: "is a Message This Test").insert(db) + try TestMessage(body: "this message is a test").insert(db) + try TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ) + .insert(db) + try TestMessage(body: "Do test messages contain content?").insert(db) + try TestMessage(body: "Is messaging awesome?").insert(db) + } + } + + // MARK: -- returns results + it("returns results") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "Message", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage(body: "This content is something which includes a combination of test words found in another message"), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- adds a wildcard to the final part + it("adds a wildcard to the final part") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "This mes", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage(body: "This content is something which includes a combination of test words found in another message"), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- does not add a wildcard to other parts + it("does not add a wildcard to other parts") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "mes Random", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(beEmpty()) + } + + // MARK: -- finds similar words without the wildcard due to the porter tokenizer + it("finds similar words without the wildcard due to the porter tokenizer") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "message z", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- finds results containing the words regardless of the order + it("finds results containing the words regardless of the order") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "is a message", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- does not find quoted parts out of order + it("does not find quoted parts out of order") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "\"this is a\" \"test message\"", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "Do test messages contain content?") + ])) + } + } + } + } +} diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index f583b8b3c..8c51c3c49 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -4,6 +4,8 @@ import Combine import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit @testable import Session diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 2750a3803..eaf4b915b 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -4,6 +4,8 @@ import Combine import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit @testable import Session