Skip to content

Instantly share code, notes, and snippets.

@oguzhanvarsak
Last active October 20, 2024 17:33
Show Gist options
  • Save oguzhanvarsak/9f572577b89ac83f07cda57d24a5316c to your computer and use it in GitHub Desktop.
Save oguzhanvarsak/9f572577b89ac83f07cda57d24a5316c to your computer and use it in GitHub Desktop.
Interview Questions for iOS Developers

Interview Questions for iOS Developers

1. Classes vs structs

In Swift, structs are value types whereas classes are reference types. When you copy a struct, you end up with two unique copies of the data. When you copy a class, you end up with two references to one instance of the data. It’s a crucial difference, and it affects your choice between classes or structs. (+ Class extendable, struct does not.)

2. What’s the difference between var and let? Which one would you choose for properties in a struct and why?

Both let and var are for creating variables in Swift. let helps you create immutable variables (constants) while on the other hand var creates mutable variables.

3. What does the mutating keyword mean?

The mutating keyword lets callers know that the method is going to make the value change.

4. What’s Optional? What mechanism lays behind that? What’s unconditional unwrapping?

You use the Optional type whenever you use optional values, even if you never type the word Optional. Swift’s type system usually shows the wrapped type’s name with a trailing question mark (?) instead of showing the full type name. For example, if a variable has the type Int?, that’s just another way of writing Optional. The shortened form is preferred for ease of reading and writing code.

let shortForm: Int? = Int("42")
let longForm: Optional<Int> = Int("42")

5. How can we unwrap an Optional value? Additional questions: What’s optional chaining, optional binding, and the nil-coalescing operator?

  • Optional chaining = We first find the stock code/symbol by calling the findStockCode function. And then we calculate the total cost needed when buying 100 shares of the stock.
if let stock = findStockCode("Apple") {
    if let sharePrice = stock.price {
        let totalCost = sharePrice * 100
        println(totalCost)
    }
}
  • Optional binding = If stockCode contains a value, unwrap it, set its value to tempStockCode and execute the conditional block. Otherwise, just skip it the block
if let tempStockCode = stockCode {
    let message = text + tempStockCode
    println(message)
}

OR

guard let tempStockCode = stockCode else { return }

Note: "guard let" allows you to use variable later while you can only use the variable in if-let closure.

  • nil-coalescing = Swift's nil coalescing operator helps you solve this problem by either unwrapping an optional if it has a value, or providing a default if the optional is empty.
let unwrapped = name ?? "not found"

6. What is SOLID? Can you name and describe those principles?

In software engineering, SOLID is a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable

  • S - Single-responsiblity Principle
  • O - Open-closed Principle
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

7. What’s the application and controller lifecycle?

For more detailed explanation step by step.

loadView -> viewDidLoad -> viewWillAppear -> viewWillLayoutSubviews -> viewDidLayoutSubviews -> viewDidAppear -> viewWillDisappear -> viewDidDisappear -> deinit -> didReceiveMemoryWarning() -> viewWillTransition(to:with:)

8. What is Core Data?

Core Data is the M in MVC, the model layer of your application. Even though Core Data can persist data to disk, data persistence is actually an optional feature of the framework. Core Data is first and foremost a framework for managing an object graph.

9. What’s reuseIdentifier in cells, and what’s the prepareForReuse method is for?

  • reuseIdentifier : As a cell scrolls out of the viewable area of the screen, the object representing it gets reused for cells scrolling on to the screen. The reuse identifier tells the system that an object can be reused for a cell entering the screen for which you request the same identifier.
  • prepareForReust : Prepares a reusable cell for reuse by the table view's delegate.

10. What is intrinsicContentSize?

Most views have an intrinsic content size, which refers to the amount of space the view needs for its content to appear in an ideal state. For example, the intrinsic content size of a UILabel will be the size of the text it contains using whatever font you have configured it to use.

11. Constraint priorities, hugging priority, and compression resistance priority: What are those, and how do they work?

  • Constraint priority is a number to determine how important is that constraint. The number can range from 1 to 1000, the higher the number goes, the more important a constraint is.
  • Hugging priority sets the priority with which a view resists being made larger than its intrinsic size. Setting a larger value to this priority indicates that we don’t want the view to grow larger than its content.
  • Compression resistance priority sets the priority with which a view resists being made smaller than its intrinsic size. Setting a higher value means that we don’t want the view to shrink smaller than the intrinsic content size.

12. What’s a difference between the POST and GET methods?

  • GET - When you get some data from URL Like name, address, gender etc. GET methods is only use for retrive data from URL.
  • POST - When you send some data on server then use post methods.

13. How would you store sensitive user data

In Order to save our apps sensitive data, we should use Security services provided by Apple.

Keychain Services API helps you solve these problems by giving your app a way to store the small amount of user data in an encrypted database called the keychain. In the keychain, you are free to save passwords and other secrets that the user explicitly cares about, such as credit card information or even short sensitive notes.

14. What’s defer?

A defer statement is used for executing code just before transferring program control outside of the scope that the statement appears in.

func updateImage() {
    defer { print("Did update image") }

    print("Will update image")
    imageView.image = updatedImage
}

// Will update Image
// Did update image

15. What are closures?

In Swift, a closure is a special type of function without the function name.

{ (parameters) -> returnType in
   // statements
}
var greet = {
  print("Hello, World!")
}

greet()
// closure that accepts one parameter
let greetUser = { (name: String)  in
    print("Hey there, \(name).")
}

// closure call
greetUser("Delilah")

16. What’s an autoclosure?

@autoclosure automatically creates a closure from an argument passed to a function. Turning an argument into a closure allows us to delay the actual request of the argument.

let isDebuggingEnabled: Bool = false

func debugLog(_ message: () -> String) {
    /// You could replace this in projects with #if DEBUG
    if isDebuggingEnabled {
        print("[DEBUG] \(message())")
    }
}

let person = Person(name: "Bernie")
debugLog({ person.description })

// Prints:
// -

The message() closure call is only called when debugging is enabled. You can see that we now need to pass in a closure argument to the debugLog method which doesn’t look so nice.

We can improve this code by making use of the @autoclosure keyword:

let isDebuggingEnabled: Bool = false
 
func debugLog(_ message: @autoclosure () -> String) {
    /// You could replace this in projects with #if DEBUG
    if isDebuggingEnabled {
        print("[DEBUG] \(message())")
    }
}

let person = Person(name: "Bernie")
debugLog(person.description)

// Prints:
// - 

The logic within the debugLog method stays the same and still has to work with a closure. However, on the implementation level, we can now pass on the argument as if it were a normal expression. It looks both clean and familiar while we did optimize our debug logging code.

@autoclosure allows delaying an argument’s actual computing, just like we’ve seen before with lazy collections and lazy properties. In fact, if debugging is not enabled, we’re no longer computing debug descriptions while we did before!

17. What do escaping and unescaping mean?

  • @nonescaping closures: When passing a closure as the function argument, the closure gets execute with the function’s body and returns the compiler back. As the execution ends, the passed closure goes out of scope and have no more existence in memory.

    Lifecycle of the @nonescaping closure:

    1. Pass the closure as function argument, during the function call.
    2. Do some additional work with function.
    3. Function runs the closure.
    4. Function returns the compiler back.
     func getSumOf(array:[Int], handler: ((Int)->Void)) {
         //step 2
         var sum: Int = 0
         for value in array {
             sum += value
         }
         
         //step 3
         handler(sum)
     }
     
     func doSomething() {
         //setp 1
         self.getSumOf(array: [16,756,442,6,23]) { [weak self](sum) in
             print(sum)
             //step 4, finishing the execution
         }
     }
     //It will print the sumof all the given numbers.
  • @escaping closures: When passing a closure as the function argument, the closure is being preserve to be execute later and function’s body gets executed, returns the compiler back. As the execution ends, the scope of the passed closure exist and have existence in memory, till the closure gets executed.

    There are several ways to escaping the closure:

    • Storage: When you need to preserve the closure in storage that exist in the memory, past of the calling function get executed and return the compiler back. (Like waiting for the API response)
    • Asynchronous Execution: When you are executing the closure asynchronously on dispatch queue, the queue will hold the closure in memory for you, to be used in future. In this case you have no idea when the closure will get executed.

    Lifecycle of the @escaping closure:

    1. Pass the closure as function argument, during the function call.
    2. Do some additional work in function.
    3. Function execute the closure asynchronously or stored.
    4. Function returns the compiler back.
     func getSumOf(array:[Int], handler: @escaping ((Int)->Void)) {
         //step 2
         var sum: Int = 0
         for value in array {
             sum += value
         }
         //step 3
         Globals.delay(0.3, closure: {
             handler(sum)
         })
     }
     
     func doSomething() {
         //setp 1
         self.getSumOf(array: [16,756,442,6,23]) { [weak self](sum) in
             print(sum)
             //step 4, finishing the execution
         }
     }
     //Here we are calling the closure with the delay of 0.3 seconds
     //It will print the sumof all the passed numbers.

18. Memory management

In Swift, memory management is handled by Automatic Reference Counting (ARC). Whenever you create a new instance of a class ARC allocates a chunk of memory to store information about the type of instance and values of stored properties of that instance. Every instance of a class also has a property called reference count, which keeps track of all the properties, constants, and variables that have a strong reference to itself. A strong reference is basically a pointer that increments the reference count of the instance of a class it is pointing to with one. Whenever the reference count of an object reaches zero that object will be deallocated.

19. How do you find and resolve memory leaks?

  • With Xcode Instruments:
    1. We open the Xcode leaks instrument first and press the record button
    2. When the app automatically launches, we press on the navigate button that presents our leaking view controller
    3. We simply pop it using navigation item’s back button
    4. Finally, we observe the effect in the leaks instrument and memory graph.
  • With Xcode itself : For more detailed information

20. How to avoid retain cycles

  • The parent must set the parent pointer in the child to nil when the relationship is broken.
  • The design must guarantee that the parent pointer is always valid for the child's lifetime (or if the parent uses autorelease to free children, valid except in the child's dealloc method).

21. Bounds vs frame

  • frame = a view’s location and size using the parent view’s coordinate system (important for placing the view in the parent)
  • bounds = a view’s location and size using its own coordinate system (important for placing the view’s content or subviews within itself)

22. GCD (Grand Central Dispatch) and how to use it

Under the hood it manages a shared thread pool. With GCD you add blocks of code or work items to dispatch queues and GCD decides which thread to execute them on.

GCD provides three main types of queues:

  • Main queue: runs on the main thread and is a serial queue.
  • Global queues: concurrent queues that are shared by the whole system. There are four such queues with
  • different priorities : high, default, low, and background. The background priority queue has the lowest priority and is throttled in any I/O activity to minimize negative system impact.
  • Custom queues: queues that you create which can be serial or concurrent. Requests in these queues actually end up in one of the global queues.

23. What collection types are there in Swift?

  • Array, set, dictionary.

24. Arrays vs sets vs dictionaries

  • Arrays are ordered collections of values.
  • Sets are unordered collections of unique values.
  • Dictionaries are unordered collections of key-value associations.

25. What’s KVO and KVC?

  • KVC is a form of coding that allows you to access an object’s properties indirectly, using strings to access them instead of the property’s accessors or instead of accessing the variables directly. To enable such mechanism, your classes must comply to the NSKeyValueCoding informal protocol.

     class Profile: NSObject {
     	var firstName: String
     	var lastName: String
     	var customProfile: Profile
     }

    In KVC; Instead of directly assigning the values to properties, or using (#If available) setter methods of objects, we are doing in a way simply assigning values to keys/keyPaths. So we use keys and values, this technique is called Key Value Coding(KVC).

     self.setValue: “Stark” for key: “lastName”
  • Key-value observing is a Cocoa programming pattern you use to notify objects about changes to properties of other objects. It's useful for communicating changes between logically separated parts of your app—such as between models and views. You can only use key-value observing with classes that inherit from NSObject.

     class Person: NSObject {
         @objc dynamic var age: Int
         @objc dynamic var name: String
     }

    API is using a closure callback for delivering the change notification right in the place where the subscription started.

     class PersonObserver {
     
     	var kvoToken: NSKeyValueObservation?
         
     	func observe(person: Person) {
     		kvoToken = person.observe(\.age, options: .new) { (person, change) in
     			guard let age = change.new else { return }
     			print("New age is: \(age)")
     		}
     	}
     
     	deinit {
     		kvoToken?.invalidate()
     	}
     }

26. Chain of responsibility

27. Enums, raw value, assocd value

enum Planet {
	case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
	case upc(Int, Int, Int, Int) 	// Associated Type
	case tab = "\t"					// Raw Value
}

28. Give some examples of methods for collection operations from the standard library. (What do map and reduce do and how can these methods be helpful in day-to-day development?)

  • Use map to loop over a collection and apply the same operation to each element in the collection. The map function returns an array containing the results of applying a mapping or transform function to each item.
  • Use filter to loop over a collection and return an Array containing only those elements that match an include condition.
  • Use reduce to combine all items in a collection to create a single new value.
  • Flatmap is used to flatten a collection of collections . But before flattening the collection, we can apply map to each elements. Apple docs says: Returns an array containing the concatenated results of calling the given transformation with each element of this sequence.

29. Delegation

The core purpose of the delegate pattern is to allow an object to communicate back to its owner in a decoupled way. By not requiring an object to know the concrete type of its owner, we can write code that is much easier to reuse and maintain.

30. Protocol oriented programming

Protocols allow you to group similar methods, functions and properties. Swift lets you specify these interface guarantees on class, struct and enum types. Only class types can use base classes and inheritance. An advantage of protocols in Swift is that objects can conform to multiple protocols. When writing an app this way, your code becomes more modular. Think of protocols as building blocks of functionality. When you add new functionality by conforming an object to a protocol, you don’t build a whole new object. That’s time-consuming. Instead, you add different building blocks until your object is ready.

31. What’s a certificate pinning?

SSL Certificate Pinning, or pinning for short, is the process of associating a host with its certificate or public key. Once you know a host’s certificate or public key, you pin it to that host.

32. What are the differences and similarities between a GCD and an NSOperation?

Comparing NSOperation and Grand Central Dispatch is comparing apples and oranges. Why is that? With the introduction of Grand Central Dispatch, Apple refactored NSOperation to work on top of Grand Central Dispatch. The NSOperation API is a higher level abstraction of Grand Central Dispatch. If you are using NSOperation, then you are implicitly using Grand Central Dispatch.

Grand Central Dispatch is a low-level C API that interacts directly with Unix level of the system. NSOperation is an Objective-C API and that brings some overhead with it. Instances of NSOperation need to be allocated before they can be used and deallocated when they are no longer needed. Even though this is a highly optimized process, it is inherently slower than Grand Central Dispatch, which operates at a lower level.

33. Generics and constraints

Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

34. Reference vs Value types

  • Reference Type: Bob has a phone number, written on a piece of paper. He shares it with Alice. Alice doesn’t write down the phone number for herself, but instead remembers that Bob has it. When she needs the phone number, she uses Bob’s piece of paper. When Alice accidentally changes one digit of the phone number, Bob’s phone number changes too. Picture this as Bob and Alice both holding the piece of paper the phone number is written on.
  • Value Type: Bob has a phone number, and he gives it to Alice. Alice writes it down and now has her own copy. When she accidentally changes it, only her copy changes, and not the original phone number Bob has. Both Bob and Alice have their unique copy of the phone number.

35. Final keyword

The final keyword communicates to the compiler that the class cannot and should not be subclassed. (The compiler marks entities as final if it knows it can safely do so.)

36. Abstract class and how to achieve it

There are no abstract classes in Swift (just like Objective-C). Your best bet is going to be to use a Protocol.

37. What’s a dispatch group?

A group of tasks that you monitor as a single unit.

38. Does swift support multiple inheritance?

Multiple inheritance is an object-oriented concept in which a class can inherit behavior and attributes from more than one parent class. Along with single inheritance and composition, multiple inheritance offers another way of sharing code between classes that can be very beneficial if used correctly. Although multiple inheritance is a standard feature of some programming languages, like C++, it is not the case for Swift. In Swift a class can conform to multiple protocols, but inherit from only one class. Value types, such as struct and enum, can conform to multiple protocols only. Protocols with default implementations give us just enough flexibility to approach multiple inheritance very closely.

39. Dynamic vs static dispatch

To achieve Dynamic Dispatch, we use inheritance, subclass a base class and then override an existing method of the base class. Also, we can make use of dynamic keyword and we need to prefix it with @objc keyword so as to expose our method to Objective-C runtime. They are pretty fast when compared to Dynamic dispatch as the compiler is able to locate where the instructions are, at compile time. So, when the function is called, the compiler jumps directly to the memory address of the function to perform the operation. This result is huge performance gains and certain compiler optimizations as well such as inlining. To achieve Static Dispatch, we need to make use of final and static as both of them ensures that the class and method cannot be overridden. In this type of dispatch, the implementation is chosen at runtime instead of compile-time, which adds some overhead.

40. What’s a serial/concurrent queue?

Concurrent and Serial queues help us to manage how we execute tasks and help to make our applications run faster, more efficiently, and with improved responsiveness. We can create queues easily using the DispatchQueue class which is built on top of the Grand Central Dispatch (GCD) queue.

41. Thread safety

I personally define thread safety as a class's ability to ensure "correctness" when multiple threads attempt to use it at the same time. If different threads accessing some piece of shared state at very specific moments at the same time cannot result in your class ending up in an unexpected/broken state, then your code is thread-safe. If that's not the case, you can use the OS's synchronization APIs to orchestrate when the threads can access that information, making so your class's shared state is always correct and predictable.

42. What do weak and unowned mean? What’s the difference?

  • A weak reference keeps a weak reference to the instance it references. This means that the reference to the instance is not taken into account by ARC. Remember that an instance is deallocated if no other objects have a strong reference to the instance.
  • Unowned references are similar to weak references in that they don't keep a strong reference to the instance they are referencing. They serve the same purpose as weak references, that is, they avoid strong reference cycles.
    • The first difference you need to know about is that an unowned reference is always expected to have a value. This is not true for weak references, which are set to nil if the instance they reference is deallocated. When that happens, the reference is set to nil.
    • Because a weak reference can be set to nil, it is always declared as an optional. That is the second difference between weak and unowned references. The value of a weak reference needs to be unwrapped before it can be accessed whereas you can directly access the value of an unowned reference.

43. Weak vs strong

A strong reference means that you want to “own” the object you are referencing with this property/variable. In contrast, with a weak reference you signify that you don’t want to have control over the object’s lifetime.

44. Lazy variable

These variables are created using a function you specify only when that variable is first requested. If it's never requested, the function is never run, so it does help save processing time.

45. ARC (Automatic Reference Counting)

Every time you create a new instance of a class, ARC allocates a chunk of memory to store information about that instance. This memory holds information about the type of the instance, together with the values of any stored properties associated with that instance. Additionally, when an instance is no longer needed, ARC frees up the memory used by that instance so that the memory can be used for other purposes instead. This ensures that class instances don’t take up space in memory when they’re no longer needed. However, if ARC were to deallocate an instance that was still in use, it would no longer be possible to access that instance’s properties, or call that instance’s methods. Indeed, if you tried to access the instance, your app would most likely crash. To make sure that instances don’t disappear while they’re still needed, ARC tracks how many properties, constants, and variables are currently referring to each class instance. ARC will not deallocate an instance as long as at least one active reference to that instance still exists.

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    deinit {
        print("\(name) is being deinitialized")
    }
}

46. Have you heard about method swizzling? What is that? Can it be done in Swift?

Swizzling (other languages call this “monkey patching”) is the process of replacing a certain functionality or adding custom code before the original code is called. For example, you could swizzle UIViewController.viewDidAppear to be informed whenever a view controller is displayed. This affects all uses of UIViewController within your process/app, including controllers owned by third-party frameworks or Apple frameworks.

47. Consider the following scenario: You have a table view with a large number of cells containing images. How would you optimize the performance and scrolling smoothness of the table view?

To optimize the performance and scrolling smoothness of a table view with a large number of cells containing images, the following strategies can be employed:

Implement Cell Reuse: Use cell reuse by utilizing the dequeueReusableCell(withIdentifier:forIndexPath:) method provided by UITableView. This allows cells to be recycled and reused instead of creating new cells for each row. By reusing cells, memory consumption is reduced and scrolling performance is improved.

Asynchronous Image Loading: Download or load images asynchronously on a background queue, so that the main UI thread is not blocked. This can be achieved by using libraries such as SDWebImage or AlamofireImage, which handle asynchronous image loading and caching.

Image Caching: Implement image caching to avoid redundant downloads or fetches from the network. Caching can be done using tools like NSCache or third-party libraries such as SDWebImage or Kingfisher. Caching improves performance by storing images in memory or on disk, reducing the need for repeated network requests.

Lazy Loading: Load images lazily as the cells become visible on the screen. This can be achieved by implementing a technique known as "lazy loading" where you load the images for only the visible cells and postpone loading the rest until they become visible. This approach prevents unnecessary loading of off-screen images and improves scrolling performance.

Image Resizing: Resize the images to the appropriate dimensions for display in the table view cells. Large images consume more memory and take longer to render. By resizing the images to match the cell's size or a predefined size, you can significantly improve performance.

Prefetching: Implement the prefetchRows(at:) method provided by UITableViewDataSourcePrefetching protocol to prefetch the images for upcoming cells as the user scrolls. This can be done by initiating image loading or downloading in advance for cells that are likely to become visible next. Prefetching enhances the user experience by reducing the lag when new cells appear on the screen.

Reduce Cell Complexity: Simplify the cell's layout and contents. Minimize the number of subviews, avoid complex hierarchy, and use lightweight UI components. By reducing the complexity of the cell, the rendering process becomes faster, resulting in smoother scrolling.

Compress Images: Compress the images to reduce their file size without significant loss of quality. Smaller image sizes lead to faster downloads and improved rendering performance.

By employing these optimization techniques, you can ensure a smooth and efficient scrolling experience for a table view with a large number of cells containing images.

48. What is multithreading, and why is it important in iOS development?

Multithreading is the simultaneous execution of multiple threads to achieve better performance and responsiveness in apps. It's crucial in iOS development to prevent UI freezes and improve user experience.

49. Explain the difference between a process and a thread.

A process is an independent program with its own memory space, while a thread is a unit of execution within a process. Multiple threads within a process share the same memory space.

50. What is the Global Dispatch Queue (GCD) in Swift? How does it help manage concurrency?

  • Answer: GCD is a concurrency framework that simplifies multithreaded programming by providing a pool of threads managed by the system. It manages tasks and dispatches them to available threads, improving efficiency.

51. Describe the different types of dispatch queues available in GCD.

GCD offers two main types of queues: serial queues (execute tasks in order) and concurrent queues (execute tasks concurrently).

52. What is the main queue in GCD, and why is it important to perform UI updates on it?

The main queue is a serial queue specifically for UI updates. It's crucial to use it for UI updates because UI changes should be performed on the main thread to ensure smooth and responsive user interactions.

53. Explain the concept of a concurrent queue and a serial queue in GCD.

A concurrent queue executes multiple tasks concurrently, while a serial queue executes tasks in the order they are added, one at a time.

54. What is a race condition, and how can it occur in a multithreaded environment?

A race condition occurs when two or more threads access shared data simultaneously, leading to unpredictable results. It can happen when there's no synchronization mechanism in place.

55. How can you prevent race conditions and ensure thread safety in your iOS app?

Use synchronization mechanisms like locks, serial queues, and GCD barriers to protect shared resources from concurrent access.

56. What is the purpose of the @synchronized directive in Objective-C? How does it work?

@synchronized is used to create a critical section that ensures only one thread can access the specified code block at a time, preventing race conditions.

57. What is a deadlock, and how can it occur in multithreaded programs?

A deadlock occurs when two or more threads wait indefinitely for each other to release resources. It can happen when resources are acquired in a different order by different threads.

58. What are barriers in GCD, and when might you use them?

Barriers are used in concurrent queues to enforce synchronization points. They allow you to ensure that certain tasks are completed before or after other tasks, preventing data inconsistency.

Source

@rashmisingh121
Copy link

Hi @oguzhanvarsak Thanks for sharing this. I have found one good playlist for iOS interview questions and answers. It will be good if you can add that as-well in the list. https://www.youtube.com/watch?v=Lzl9h_MovJg&list=PLV7VzbWXa60EiWHqcH_pNjuVYxW0JpcET

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment