Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Offline mode #435

Open
wants to merge 73 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
afe5203
LocalStore
vdkdamian Dec 20, 2022
36920d4
FindExplain not working (yet)
vdkdamian Dec 20, 2022
22fb134
Revert "FindExplain not working (yet)"
vdkdamian Dec 20, 2022
40f21ab
Fix
vdkdamian Dec 21, 2022
ad7e586
Fix, Print
vdkdamian Dec 21, 2022
ccb65b3
Fix
vdkdamian Dec 21, 2022
81fccd6
useLocalStore fix
vdkdamian Dec 21, 2022
fffd358
Filename change
vdkdamian Dec 21, 2022
30a309b
Test
vdkdamian Dec 21, 2022
cd32e30
Changed queryIdentifier
vdkdamian Dec 21, 2022
9b0cea9
Fix
vdkdamian Dec 21, 2022
c455849
Classname
vdkdamian Dec 21, 2022
6157b30
QueryDate, print
vdkdamian Dec 21, 2022
35e1c5f
print
vdkdamian Dec 21, 2022
cab5ae7
Print decode error
vdkdamian Dec 21, 2022
a5e5750
queryIdentifiers
vdkdamian Dec 21, 2022
e9e8237
Sets fix
vdkdamian Dec 21, 2022
7d3f12c
Fix
vdkdamian Dec 21, 2022
ecfeec8
Cleaned
vdkdamian Dec 21, 2022
0a375fb
sorted Sets names
vdkdamian Dec 21, 2022
3b2469c
Comment
vdkdamian Dec 21, 2022
1746e29
Fix: added fields set
vdkdamian Dec 21, 2022
2d6a9da
Print responseError
vdkdamian Dec 21, 2022
faba328
Removed print
vdkdamian Dec 21, 2022
aa64598
Created no internet connection error
vdkdamian Dec 21, 2022
f96380a
Compare error
vdkdamian Dec 21, 2022
3acf50c
Fix
vdkdamian Dec 21, 2022
0874000
Try new error code for fix iOS
vdkdamian Dec 21, 2022
517499c
Connection not allowed
vdkdamian Dec 21, 2022
f0e9cee
Function changes
vdkdamian Dec 22, 2022
6b32d79
FindAll
vdkdamian Dec 22, 2022
05d3821
First
vdkdamian Dec 22, 2022
0e44109
Save object
vdkdamian Dec 22, 2022
471863c
saveLocally
vdkdamian Dec 22, 2022
56c6343
hasNoInternetConnection
vdkdamian Dec 22, 2022
6ac8a93
FetchObject
vdkdamian Dec 22, 2022
6d7d95d
HiddenFile
vdkdamian Dec 23, 2022
1059b3b
uniqueObjectsById, Create fetchobjects when policy enabled
vdkdamian Dec 23, 2022
799ed42
Cleanup
vdkdamian Dec 23, 2022
345993a
Comment
vdkdamian Dec 23, 2022
4fb6971
Moved some code
vdkdamian Dec 23, 2022
158a151
Make sure new FetchObjects are unique
vdkdamian Dec 23, 2022
f3566eb
Fetching Local Store
vdkdamian Dec 24, 2022
5ba3892
Print
vdkdamian Dec 25, 2022
0e258f4
Error change
vdkdamian Dec 25, 2022
2c0dbf1
Change function
vdkdamian Dec 25, 2022
daa251d
Revert
vdkdamian Dec 25, 2022
3bfce97
Lint fix
vdkdamian Dec 25, 2022
04826e0
Remove files if corrupted, Remove files if empty, code clean
vdkdamian Dec 25, 2022
ca175d0
Fixes
vdkdamian Dec 26, 2022
455c78b
Fix: don't create when an error
vdkdamian Dec 27, 2022
65843f7
Fix
vdkdamian Dec 27, 2022
25b8ea9
Print
vdkdamian Dec 27, 2022
46c0dd5
Fix where save was executed before result
vdkdamian Dec 27, 2022
a0428e9
Cleanup
vdkdamian Dec 27, 2022
9bf6c06
Offline Mode playground
vdkdamian Dec 27, 2022
5f455dc
Fix
vdkdamian Dec 28, 2022
9e090cc
Offline mode playground
vdkdamian Dec 28, 2022
7a2d853
Linting
vdkdamian Dec 29, 2022
ce5760f
Linting
vdkdamian Dec 29, 2022
1f150ef
Linting
vdkdamian Dec 29, 2022
ad3985a
Fix
vdkdamian Dec 30, 2022
8dbe697
CodeCov
vdkdamian Dec 30, 2022
e82c4c9
CodeCov: useLocalStore
vdkdamian Dec 30, 2022
58e6a35
tests: LocalStorage
vdkdamian Dec 30, 2022
345270a
tests: testFetchLocalStore
vdkdamian Dec 30, 2022
9c54f28
Tests
vdkdamian Dec 30, 2022
a372918
MockLocalStorage
vdkdamian Dec 30, 2022
42cf041
tests: Query local
vdkdamian Dec 30, 2022
f51defd
codecov: Mock QueryObjects
vdkdamian Dec 31, 2022
835a778
Revert Mock
vdkdamian Dec 31, 2022
f887fe8
codecov: Expand saveLocally
vdkdamian Jan 1, 2023
e34dd82
codecov: Check error
vdkdamian Jan 1, 2023
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
@@ -0,0 +1,140 @@
//: [Previous](@previous)

//: For this page, make sure your build target is set to ParseSwift (macOS) and targeting
//: `My Mac` or whatever the name of your mac is. Also be sure your `Playground Settings`
//: in the `File Inspector` is `Platform = macOS`. This is because
//: Keychain in iOS Playgrounds behaves differently. Every page in Playgrounds should
//: be set to build for `macOS` unless specified.

import PlaygroundSupport
import Foundation
import ParseSwift
PlaygroundPage.current.needsIndefiniteExecution = true

//: In order to enable offline mode you need to set offlinePolicy to either `create` or `save`
//: `save` will allow you to save and fetch objects.
//: `create` will allow you to create, save and fetch objects.
//: Note that `create` will require you to enable customObjectIds.
ParseSwift.initialize(applicationId: "applicationId",
clientKey: "clientKey",
masterKey: "masterKey",
serverURL: URL(string: "http://localhost:1337/1")!,
offlinePolicy: .create,
requiringCustomObjectIds: true,
usingEqualQueryConstraint: false,
usingDataProtectionKeychain: false)

struct GameScore: ParseObject {
var objectId: String?
var createdAt: Date?
var updatedAt: Date?
var ACL: ParseACL?
var originalData: Data?

//: Your own properties.
var points: Int?
var timeStamp: Date? = Date()
var oldScore: Int?
var isHighest: Bool?

/*:
Optional - implement your own version of merge
for faster decoding after updating your `ParseObject`.
*/
func merge(with object: Self) throws -> Self {
var updated = try mergeParse(with: object)
if updated.shouldRestoreKey(\.points,
original: object) {
updated.points = object.points
}
if updated.shouldRestoreKey(\.timeStamp,
original: object) {
updated.timeStamp = object.timeStamp
}
if updated.shouldRestoreKey(\.oldScore,
original: object) {
updated.oldScore = object.oldScore
}
if updated.shouldRestoreKey(\.isHighest,
original: object) {
updated.isHighest = object.isHighest
}
return updated
}
}

var score = GameScore()
score.points = 200
score.oldScore = 10
score.isHighest = true
do {
try score.save()
} catch {
print(error)
}

//: If you want to use local objects when an internet connection failed,
//: you need to set useLocalStore()
let afterDate = Date().addingTimeInterval(-300)
var query = GameScore.query("points" > 50,
"createdAt" > afterDate)
.useLocalStore()
.order([.descending("points")])

//: Query asynchronously (preferred way) - Performs work on background
//: queue and returns to specified callbackQueue.
//: If no callbackQueue is specified it returns to main queue.
query.limit(2)
.order([.descending("points")])
.find(callbackQueue: .main) { results in
switch results {
case .success(let scores):

assert(scores.count >= 1)
scores.forEach { score in
guard let createdAt = score.createdAt else { fatalError() }
assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok")
print("Found score: \(score)")
}

case .failure(let error):
if error.equalsTo(.objectNotFound) {
assertionFailure("Object not found for this query")
} else {
assertionFailure("Error querying: \(error)")
}
}
}

//: Query synchronously (not preferred - all operations on current queue).
let results = try query.find()
assert(results.count >= 1)
results.forEach { score in
guard let createdAt = score.createdAt else { fatalError() }
assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok")
print("Found score: \(score)")
}

//: Query first asynchronously (preferred way) - Performs work on background
//: queue and returns to specified callbackQueue.
//: If no callbackQueue is specified it returns to main queue.
query.first { results in
switch results {
case .success(let score):

guard score.objectId != nil,
let createdAt = score.createdAt else { fatalError() }
assert(createdAt.timeIntervalSince1970 > afterDate.timeIntervalSince1970, "date should be ok")
print("Found score: \(score)")

case .failure(let error):
if error.containedIn([.objectNotFound, .invalidQuery]) {
assertionFailure("The query is invalid or the object is not found.")
} else {
assertionFailure("Error querying: \(error)")
}
}
}

PlaygroundPage.current.finishExecution()
//: [Next](@next)
18 changes: 18 additions & 0 deletions ParseSwift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,13 @@
91F346C3269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; };
91F346C4269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; };
91F346C5269B88F7005727B6 /* ParseCloudViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */; };
CBEF514C295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; };
CBEF514D295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; };
CBEF514E295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; };
CBEF514F295E40CB0052E598 /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF514B295E40CB0052E598 /* LocalStorage.swift */; };
CBEF5152295EF5DA0052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; };
CBEF5153295EF5E20052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; };
CBEF5154295EF5E30052E598 /* ParseLocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */; };
F971F4F624DE381A006CB79B /* ParseEncoderExtraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F971F4F524DE381A006CB79B /* ParseEncoderExtraTests.swift */; };
F97B45CE24D9C6F200F4A88B /* ParseCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B45B424D9C6F200F4A88B /* ParseCoding.swift */; };
F97B45CF24D9C6F200F4A88B /* ParseCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B45B424D9C6F200F4A88B /* ParseCoding.swift */; };
Expand Down Expand Up @@ -1449,6 +1456,8 @@
91F346B8269B766C005727B6 /* CloudViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudViewModel.swift; sourceTree = "<group>"; };
91F346BD269B77B5005727B6 /* CloudObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudObservable.swift; sourceTree = "<group>"; };
91F346C2269B88F7005727B6 /* ParseCloudViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloudViewModelTests.swift; sourceTree = "<group>"; };
CBEF514B295E40CB0052E598 /* LocalStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalStorage.swift; sourceTree = "<group>"; };
CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseLocalStorageTests.swift; sourceTree = "<group>"; };
F971F4F524DE381A006CB79B /* ParseEncoderExtraTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseEncoderExtraTests.swift; sourceTree = "<group>"; };
F97B45B424D9C6F200F4A88B /* ParseCoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParseCoding.swift; sourceTree = "<group>"; };
F97B45B524D9C6F200F4A88B /* AnyDecodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyDecodable.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1686,6 +1695,7 @@
70385E6328563FD10084D306 /* ParsePushPayloadFirebaseTests.swift */,
70212D172855256F00386163 /* ParsePushTests.swift */,
917BA4252703DB4600F8D747 /* ParseQueryAsyncTests.swift */,
CBEF5151295EF5DA0052E598 /* ParseLocalStorageTests.swift */,
700AFE02289C3508006C1CD9 /* ParseQueryCacheTests.swift */,
7044C20525C5D6780011F6E7 /* ParseQueryCombineTests.swift */,
70C7DC1F24D20F180050419B /* ParseQueryTests.swift */,
Expand Down Expand Up @@ -2225,6 +2235,7 @@
F97B45CB24D9C6F200F4A88B /* Storage */ = {
isa = PBXGroup;
children = (
CBEF514B295E40CB0052E598 /* LocalStorage.swift */,
F97B465E24D9C7B500F4A88B /* KeychainStore.swift */,
70572670259033A700F0ADD5 /* ParseFileManager.swift */,
F97B45CC24D9C6F200F4A88B /* ParseStorage.swift */,
Expand Down Expand Up @@ -2815,6 +2826,7 @@
F97B462F24D9C74400F4A88B /* BatchUtils.swift in Sources */,
70385E802858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */,
70CE0AAD28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */,
CBEF514C295E40CB0052E598 /* LocalStorage.swift in Sources */,
4A82B7F61F254CCE0063D731 /* Parse.swift in Sources */,
F97B45EA24D9C6F200F4A88B /* ParseGeoPoint.swift in Sources */,
F97B460224D9C6F200F4A88B /* NoBody.swift in Sources */,
Expand Down Expand Up @@ -2925,6 +2937,7 @@
703B092326BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */,
70E6B016286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */,
70C5504625B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
CBEF5152295EF5DA0052E598 /* ParseLocalStorageTests.swift in Sources */,
70110D5C2506ED0E0091CC1D /* ParseInstallationTests.swift in Sources */,
70F03A562780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */,
7C4C0947285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */,
Expand Down Expand Up @@ -3129,6 +3142,7 @@
4AFDA72A1F26DAE1002AE4FC /* Parse.swift in Sources */,
70385E812858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */,
70CE0AAE28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */,
CBEF514D295E40CB0052E598 /* LocalStorage.swift in Sources */,
F97B45EB24D9C6F200F4A88B /* ParseGeoPoint.swift in Sources */,
F97B460324D9C6F200F4A88B /* NoBody.swift in Sources */,
703B093126BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */,
Expand Down Expand Up @@ -3248,6 +3262,7 @@
703B092526BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */,
70E6B018286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */,
70C5504825B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
CBEF5154295EF5E30052E598 /* ParseLocalStorageTests.swift in Sources */,
709B98572556ECAA00507778 /* ParseACLTests.swift in Sources */,
70F03A582780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */,
7C4C0949285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */,
Expand Down Expand Up @@ -3372,6 +3387,7 @@
703B092426BDFAB2005A112F /* ParseUserAsyncTests.swift in Sources */,
70E6B017286120E00043EC4A /* ParseHookFunctionTests.swift in Sources */,
70C5504725B40D5200B5DBC2 /* ParseSessionTests.swift in Sources */,
CBEF5153295EF5E20052E598 /* ParseLocalStorageTests.swift in Sources */,
70F2E2BC254F283000B2EA5C /* ParseObjectTests.swift in Sources */,
70F03A572780E8E300E5AFB4 /* ParseGoogleCombineTests.swift in Sources */,
7C4C0948285EA60E00F202C6 /* ParseInstagramAsyncTests.swift in Sources */,
Expand Down Expand Up @@ -3576,6 +3592,7 @@
F97B465924D9C78C00F4A88B /* Remove.swift in Sources */,
70385E832858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */,
70CE0AB028595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */,
CBEF514F295E40CB0052E598 /* LocalStorage.swift in Sources */,
70110D5A2506CE890091CC1D /* BaseParseInstallation.swift in Sources */,
F97B45F924D9C6F200F4A88B /* ParseError.swift in Sources */,
703B093326BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */,
Expand Down Expand Up @@ -3766,6 +3783,7 @@
F97B465824D9C78C00F4A88B /* Remove.swift in Sources */,
70385E822858EAA90084D306 /* ParseHookFunctionRequest.swift in Sources */,
70CE0AAF28595FDE00DAEA86 /* ParseHookRequestable+combine.swift in Sources */,
CBEF514E295E40CB0052E598 /* LocalStorage.swift in Sources */,
70110D592506CE890091CC1D /* BaseParseInstallation.swift in Sources */,
F97B45F824D9C6F200F4A88B /* ParseError.swift in Sources */,
703B093226BF42C2005A112F /* ParseAnonymous+combine.swift in Sources */,
Expand Down
3 changes: 2 additions & 1 deletion Sources/ParseSwift/Coding/AnyDecodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct AnyDecodable: Decodable {
}
}

protocol _AnyDecodable {
protocol _AnyDecodable { // swiftlint:disable:this type_name
var value: Any { get }
init<T>(_ value: T?)
}
Expand Down Expand Up @@ -74,6 +74,7 @@ extension _AnyDecodable {
}

extension AnyDecodable: Equatable {
// swiftlint:disable:next cyclomatic_complexity
static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool {
switch (lhs.value, rhs.value) {
#if canImport(Foundation)
Expand Down
5 changes: 2 additions & 3 deletions Sources/ParseSwift/Coding/AnyEncodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,14 @@ struct AnyEncodable: Encodable {
}

@usableFromInline
protocol _AnyEncodable {

protocol _AnyEncodable { // swiftlint:disable:this type_name
var value: Any { get }
init<T>(_ value: T?)
}

extension AnyEncodable: _AnyEncodable {}

// MARK: - Encodable

extension _AnyEncodable {
// swiftlint:disable:next cyclomatic_complexity function_body_length
func encode(to encoder: Encoder) throws {
Expand Down Expand Up @@ -110,6 +108,7 @@ extension _AnyEncodable {
}

#if canImport(Foundation)
// swiftlint:disable:next cyclomatic_complexity
private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws {
switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) {
case "c", "C":
Expand Down
7 changes: 3 additions & 4 deletions Sources/ParseSwift/Coding/ParseEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
// Created by Pranjal Satija on 7/20/20.
// Copyright © 2020 Parse. All rights reserved.
//

//===----------------------------------------------------------------------===//
// ===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
Expand All @@ -16,10 +15,10 @@
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

// ===----------------------------------------------------------------------===//
import Foundation

// swiftlint:disable type_name
/// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary`
/// containing `Encodable` values (in which case it should be exempt from key conversion strategies).
///
Expand Down
14 changes: 10 additions & 4 deletions Sources/ParseSwift/Extensions/URLSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@
responseError: Error?,
mapper: @escaping (Data) throws -> U) -> Result<U, ParseError> {
if let responseError = responseError {
guard let parseError = responseError as? ParseError else {
return .failure(ParseError(code: .unknownError,
message: "Unable to connect with parse-server: \(responseError)"))
if let urlError = responseError as? URLError,
urlError.code == URLError.Code.notConnectedToInternet || urlError.code == URLError.Code.dataNotAllowed {
return .failure(ParseError(code: .notConnectedToInternet,
message: "Unable to connect with the internet: \(responseError)"))

Check warning on line 72 in Sources/ParseSwift/Extensions/URLSession.swift

View check run for this annotation

Codecov / codecov/patch

Sources/ParseSwift/Extensions/URLSession.swift#L71-L72

Added lines #L71 - L72 were not covered by tests
} else {
guard let parseError = responseError as? ParseError else {
return .failure(ParseError(code: .unknownError,
message: "Unable to connect with parse-server: \(responseError)"))
}
return .failure(parseError)
}
return .failure(parseError)
}
guard let response = urlResponse else {
guard let parseError = responseError as? ParseError else {
Expand Down
Loading
Loading