Nsurlsessionconfiguration Background Upload on Cellular Not Working

Update notation: Felipe Laso-Marsetti updated this tutorial for Swift 5.0, Xcode 10.2 and iOS 12. Ken Toh wrote the original and Audrey Tam completed an earlier update.

Whether an app retrieves application data from a server, updates your social media status or downloads remote files to deejay, network requests are what make the magic happen. To aid you with the many requirements for network requests, Apple provides URLSession, a consummate networking API for uploading and downloading content.

In this tutorial, you lot'll learn how to build Half Tunes, an app that queries the iTunes Search API, then downloads thirty-second previews of songs. The finished app will back up background transfers and let the user intermission, resume or cancel in-progress downloads.

Getting Started

Apply the Download Materials button at the top or bottom of this tutorial to download the starter project.

The starter project contains a user interface to search for songs and display search results, networking classes with some stubbed functionality and helper methods to store and play tracks. This lets you focus on implementing the networking aspects of the app.

Build and run the project. Yous'll see a view with a search bar at the top and an empty table view below:

Half Tune App's Starter Screen

Type a query in the search bar and tap Search. The view remains empty. Don't worry though, yous'll alter this with your new URLSession calls.

URLSession Overview

Before you begin, it'southward of import to understand URLSession and its constituent classes, so have a expect at the quick overview beneath.

URLSession is both a class and a suite of classes for handling HTTP- and HTTPS-based requests:

URLSession Diagram

URLSession is the central object responsible for sending and receiving requests. Yous create it via URLSessionConfiguration, which comes in three flavors:

  • default: Creates a default configuration object that uses the disk-persisted global cache, credential and cookie storage objects.
  • ephemeral: Like to the default configuration, except that you store all of the session-related data in retentivity. Retrieve of this every bit a "individual" session.
  • background: Lets the session perform upload or download tasks in the groundwork. Transfers go along even when the app itself is suspended or terminated by the system.

URLSessionConfiguration besides lets y'all configure session backdrop such as timeout values, caching policies and HTTP headers. Refer to Apple's documentation for a full list of configuration options.

URLSessionTask is an abstract form that denotes a task object. A session creates one or more tasks to do the actual work of fetching data and downloading or uploading files.

Understanding Session Task Types

There are three types of concrete session tasks:

  • URLSessionDataTask: Use this task for Get requests to recollect data from servers to memory.
  • URLSessionUploadTask: Use this task to upload a file from disk to a web service via a Mail service or PUT method.
  • URLSessionDownloadTask: Use this task to download a file from a remote service to a temporary file location.

URLSession Task Types

You lot tin likewise suspend, resume and cancel tasks. URLSessionDownloadTask has the extra ability to pause for future resumption.

Generally, URLSession returns data in two means:

  • Via a completion handler when a job finishes, either successfully or with an error; or,
  • Past calling methods on a delegate that y'all set when you create the session.

Now that you take an overview of what URLSession can do, you're fix to put the theory into practise!

Putting Theory Into Practice

DataTask and DownloadTask

You'll start past creating a data task to query the iTunes Search API for the user's search term.

In SearchViewController.swift, searchBarSearchButtonClicked enables the network activity indicator on the status bar to bear witness the user that a network procedure is running. Then information technology calls getSearchResults(searchTerm:completion:), which is stubbed out in QueryService.swift. Yous're about to build it out to make the network asking.

In QueryService.swift, replace // TODO 1 with the post-obit:

permit defaultSession = URLSession(configuration: .default)        

And // TODO 2 with:

var dataTask: URLSessionDataTask?        

Hither's what you lot've done:

  1. Created a URLSession and initialized it with a default session configuration.
  2. Declared URLSessionDataTask, which you'll use to make a Go request to the iTunes Search web service when the user performs a search. The data task volition be re-initialized each time the user enters a new search string.

Adjacent, replace the content in getSearchResults(searchTerm:completion:) with the following:

// 1 dataTask?.abolish()      // 2 if var urlComponents = URLComponents(cord: "https://itunes.apple.com/search") {   urlComponents.query = "media=music&entity=song&term=\(searchTerm)"         // 3   guard let url = urlComponents.url else {     return   }   // 4   dataTask =      defaultSession.dataTask(with: url) { [weak self] data, response, error in      defer {       self?.dataTask = nada     }     // 5     if let mistake = error {       self?.errorMessage += "DataTask error: " +                                error.localizedDescription + "\northward"     } else if        let data = information,       let response = response as? HTTPURLResponse,       response.statusCode == 200 {              self?.updateSearchResults(information)       // vi       DispatchQueue.chief.async {         completion(self?.tracks, self?.errorMessage ?? "")       }     }   }   // seven   dataTask?.resume() }        

Taking each numbered comment in turn:

  1. For a new user query, y'all abolish any data task that already exists, considering you want to reuse the data task object for this new query.
  2. To include the user's search string in the query URL, you lot create URLComponents from the iTunes Search base URL, then fix its query string. This ensures that your search string uses escaped characters. If you go back an error bulletin, omit the media and entity components: See this forum post.
  3. The url property of urlComponents is optional, and so y'all unwrap it to url and return early if it'south nil.
  4. From the session you created, you initialize a URLSessionDataTask with the query url and a completion handler to call when the data task completes.
  5. If the asking is successful, yous call the helper method updateSearchResults, which parses the response data into the tracks array.
  6. You lot switch to the main queue to pass tracks to the completion handler.
  7. All tasks start in a suspended state by default. Calling resume() starts the data task.

In SearchViewController, have a await at the completion closure in the call to getSearchResults(searchTerm:completion:). Later hiding the action indicator, it stores results in searchResults then updates the table view.

Note: The default request method is Get. If you desire a data job to POST, PUT or DELETE, create a URLRequest with url, gear up the request's HTTPMethod property so create a data job with the URLRequest instead of with the URL.

Build and run your app. Search for any song and you'll encounter the table view populate with the relevant rail results like so:

Half Tunes Screen With Relevant Track Results

With some URLSession code, Half Tunes is now a fleck functional!

Beingness able to view song results is prissy, but wouldn't information technology exist improve if you could tap a song to download it? That'southward your next social club of business concern. You'll utilise a download task, which makes it like shooting fish in a barrel to salve the vocal snippet in a local file.

Downloading Classes

The first affair you'll need to do to handle multiple downloads is to create a custom object to hold the state of an active download.

Create a new Swift file named Download.swift in the Model group.

Open Download.swift, and add the following implementation below the Foundation import:

class Download {   var isDownloading = false   var progress: Float = 0   var resumeData: Data?   var task: URLSessionDownloadTask?   var track: Rail      init(track: Rails) {     self.rails = track   } }        

Here's a rundown of the backdrop of Download:

  • isDownloading: Whether the download is ongoing or paused.
  • progress: The fractional progress of the download, expressed as a float between 0.0 and 1.0.
  • resumeData: Stores the Data produced when the user pauses a download chore. If the host server supports it, your app can use this to resume a paused download.
  • job: The URLSessionDownloadTask that downloads the track.
  • track: The rails to download. The rail'due south url holding also acts as a unique identifier for Download.

Side by side, in DownloadService.swift, replace // TODO 4 with the post-obit property:

var activeDownloads: [URL: Download] = [:]        

This dictionary volition maintain a mapping between a URL and its agile Download, if any.

URLSession Delegates

Yous could create your download job with a completion handler, as you did when you created the data job. However, afterwards in this tutorial you'll cheque and update the download progress, which requires y'all to implement a custom delegate. And so you might besides exercise that now.

There are several session delegate protocols, listed in Apple'southward URLSession documentation. URLSessionDownloadDelegate handles chore-level events specific to download tasks.

You're going to need to set SearchViewController as the session delegate soon, so now you'll create an extension to suit to the session delegate protocol.

Open SearchViewController.swift and replace // TODO 5 with the following URLSessionDownloadDelegate extension below:

extension SearchViewController: URLSessionDownloadDelegate {   func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,                   didFinishDownloadingTo location: URL) {     impress("Finished downloading to \(location).")   }  }        

The only non-optional URLSessionDownloadDelegate method is urlSession(_:downloadTask:didFinishDownloadingTo:), which your app calls when a download finishes. For now, you'll print a bulletin whenever a download completes.

Downloading a Runway

With all the preparatory work out of the way, you're now ready to put file downloads in place. Your showtime step is to create a dedicated session to handle your download tasks.

In SearchViewController.swift, supersede // TODO 6 with the following code:

lazy var downloadsSession: URLSession = {   let configuration = URLSessionConfiguration.default      return URLSession(configuration: configuration,                      consul: self,                      delegateQueue: nada) }()        

Here, you initialize a split up session with a default configuration and specify a consul which lets you lot receive URLSession events via delegate calls. This will be useful for monitoring the progress of the task.

Setting the delegate queue to zip causes the session to create a series functioning queue to perform all calls to delegate methods and completion handlers.

Annotation the lazy creation of downloadsSession; this lets you delay the creation of the session until after you initialize the view controller. Doing that allows you to pass self as the delegate parameter to the session initializer.

Now supervene upon // TODO 7 at the stop of viewDidLoad() with the following line:

downloadService.downloadsSession = downloadsSession        

This sets the downloadsSession property of DownloadService to the session you just defined.

With your session and consul configured, you're finally ready to create a download task when the user requests a rail download.

In DownloadService.swift, supervene upon the content of startDownload(_:) with the following implementation:

// i let download = Download(runway: track) // 2 download.task = downloadsSession.downloadTask(with: runway.previewURL) // iii download.task?.resume() // 4 download.isDownloading = true // v activeDownloads[download.runway.previewURL] = download        

When the user taps a table view cell'southward Download push button, SearchViewController, acting as TrackCellDelegate, identifies the Track for this prison cell, so calls startDownload(_:) with that Track.

Here'south what's going on in startDownload(_:):

  1. You outset initialize a Download with the runway.
  2. Using your new session object, you create a URLSessionDownloadTask with the runway's preview URL and set it to the task property of the Download.
  3. You lot first the download chore past calling resume() on it.
  4. You indicate that the download is in progress.
  5. Finally, you map the download URL to its Download in activeDownloads.

Build and run your app, search for any track and tap the Download push button on a cell. Afterwards a while, you lot'll see a message in the debug console signifying that the download is complete.

Finished downloading to file:///Users/mymac/Library/Developer/CoreSimulator/Devices/74A1CE9B-7C49-46CA-9390-3B8198594088/information/Containers/Data/Awarding/FF0D263D-4F1D-4305-B98B-85B6F0ECFE16/tmp/CFNetworkDownload_BsbzIk.tmp.

The Download button is all the same showing, just you'll prepare that soon. First, y'all want to play some tunes!

Saving and Playing the Rails

When a download task completes, urlSession(_:downloadTask:didFinishDownloadingTo:) provides a URL to the temporary file location, as yous saw in the print message. Your job is to movement it to a permanent location in your app's sandbox container directory earlier you render from the method.

In SearchViewController.swift, supervene upon the print argument in urlSession(_:downloadTask:didFinishDownloadingTo:) with the post-obit code:

// ane guard allow sourceURL = downloadTask.originalRequest?.url else {   return }  let download = downloadService.activeDownloads[sourceURL] downloadService.activeDownloads[sourceURL] = cypher // two let destinationURL = localFilePath(for: sourceURL) print(destinationURL) // 3 let fileManager = FileManager.default try? fileManager.removeItem(at: destinationURL)  do {   effort fileManager.copyItem(at: location, to: destinationURL)   download?.track.downloaded = truthful } take hold of let error {   print("Could not re-create file to disk: \(mistake.localizedDescription)") } // 4 if allow index = download?.rail.index {   DispatchQueue.main.async { [weak self] in     self?.tableView.reloadRows(at: [IndexPath(row: index, section: 0)],                                 with: .none)   } }        

Here's what you lot're doing at each footstep:

  1. You lot extract the original asking URL from the task, await up the corresponding Download in your active downloads and remove it from that lexicon.
  2. You then pass the URL to localFilePath(for:), which generates a permanent local file path to save to by appending the lastPathComponent of the URL (the file name and extension of the file) to the path of the app'south Documents directory.
  3. Using fileManager, you move the downloaded file from its temporary file location to the desired destination file path, first clearing out any particular at that location before yous start the copy task. You also set the download track's downloaded property to true.
  4. Finally, y'all use the download track's alphabetize belongings to reload the corresponding prison cell.

Build and run your project, run a query, then choice any track and download it. When the download has finished, you'll see the file path location printed to your panel:

file:///Users/mymac/Library/Developer/CoreSimulator/Devices/74A1CE9B-7C49-46CA-9390-3B8198594088/information/Containers/Data/Application/087C38CC-0CEB-4895-ADB6-F44D13C2CA5A/Documents/mzaf_2494277700123015788.plus.aac.p.m4a

The Download button disappears now, considering the delegate method prepare the track's downloaded property to true. Tap the rails and you'll hear information technology play in the AVPlayerViewController as shown below:

Half Tunes App With Music Player Running

Pausing, Resuming, and Canceling Downloads

What if the user wants to pause a download or to cancel information technology altogether? In this section, you'll implement the suspension, resume and abolish features to requite the user complete control over the download process.

You'll start by assuasive the user to cancel an active download.

Canceling Downloads

In DownloadService.swift, add together the following lawmaking within cancelDownload(_:):

guard permit download = activeDownloads[track.previewURL] else {   return }  download.task?.cancel() activeDownloads[track.previewURL] = nil        

To cancel a download, you'll recall the download job from the corresponding Download in the dictionary of active downloads and call cancel() on it to abolish the chore. You'll and so remove the download object from the lexicon of active downloads.

Pausing Downloads

Your side by side task is to let your users pause their downloads and come up back to them later.

Pausing a download is similar to canceling it. Pausing cancels the download task, but also produces resume data, which contains enough information to resume the download at a later on time if the host server supports that functionality.

Note: Y'all can only resume a download under certain conditions. For instance, the resource must not have changed since you get-go requested it. For a total listing of conditions, bank check out the documentation hither.

Replace the contents of pauseDownload(_:) with the following code:

guard   allow download = activeDownloads[track.previewURL],   download.isDownloading    else {     return }  download.task?.cancel(byProducingResumeData: { data in   download.resumeData = data })  download.isDownloading = fake        

The key difference here is that you phone call cancel(byProducingResumeData:) instead of cancel(). Y'all provide a closure parameter to this method, which lets you relieve the resume information to the appropriate Download for hereafter resumption.

You too set up the isDownloading property of the Download to false to indicate that the user has paused the download.

Now that the intermission role is consummate, the next club of business organisation is to allow the user to resume a paused download.

Resuming Downloads

Replace the content of resumeDownload(_:) with the following code:

guard let download = activeDownloads[rails.previewURL] else {   return }  if let resumeData = download.resumeData {   download.job = downloadsSession.downloadTask(withResumeData: resumeData) } else {   download.task = downloadsSession     .downloadTask(with: download.track.previewURL) }  download.task?.resume() download.isDownloading = true        

When the user resumes a download, you lot cheque the appropriate Download for the presence of resume data. If found, you'll create a new download task past invoking downloadTask(withResumeData:) with the resume information. If the resume information is absent-minded for any reason, yous'll create a new download task with the download URL.

In either case, y'all'll start the task by calling resume and set up the isDownloading flag of the Download to truthful to indicate the download has resumed.

Showing and Hiding the Pause/Resume and Abolish Buttons

There's only one item left to do for these iii functions to work: You need to show or hide the Suspension/Resume and Abolish buttons, as appropriate.

To do this, TrackCell's configure(rails:downloaded:) needs to know if the track has an active download and whether it's currently downloading.

In TrackCell.swift, change configure(track:downloaded:) to configure(runway:downloaded:download:):

func configure(rail: Track, downloaded: Bool, download: Download?) {        

In SearchViewController.swift, set the call in tableView(_:cellForRowAt:):

cell.configure(track: rail,                downloaded: track.downloaded,                download: downloadService.activeDownloads[track.previewURL])        

Here, you extract the rail's download object from activeDownloads.

Dorsum in TrackCell.swift, locate // TODO 14 in configure(rail:downloaded:download:) and add the following property:

var showDownloadControls = imitation        

Then supervene upon // TODO xv with the following:

if let download = download {   showDownloadControls = true   let title = download.isDownloading ? "Intermission" : "Resume"   pauseButton.setTitle(title, for: .normal) }        

As the comment notes, a not-cypher download object means a download is in progress, so the jail cell should evidence the download controls: Pause/Resume and Cancel. Since the intermission and resume functions share the same push, you'll toggle the push button between the 2 states, as advisable.

Below this if-closure, add the following code:

pauseButton.isHidden = !showDownloadControls cancelButton.isHidden = !showDownloadControls        

Here, you bear witness the buttons for a cell just if a download is active.

Finally, replace the final line of this method:

downloadButton.isHidden = downloaded        

with the post-obit code:

downloadButton.isHidden = downloaded || showDownloadControls        

Hither, you tell the cell to hibernate the Download button if the rail is downloading.

Build and run your project. Download a few tracks concurrently and you'll be able to break, resume and abolish them at will:

Half Tunes App Screen With Pause, Resume, and Cancel Options

Showing Download Progress

At this point, the app is functional, but it doesn't prove the progress of the download. To improve the user experience, you'll modify your app to listen for download progress events and display the progress in the cells. There's a session delegate method that's perfect for this job!

First, in TrackCell.swift, replace // TODO 16 with the post-obit helper method:

func updateDisplay(progress: Float, totalSize : String) {   progressView.progress = progress   progressLabel.text = String(format: "%.1f%% of %@", progress * 100, totalSize) }        

The track prison cell has progressView and progressLabel outlets. The delegate method will call this helper method to set their values.

Side by side, in SearchViewController.swift, add the following delegate method to the URLSessionDownloadDelegate extension:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,                   didWriteData bytesWritten: Int64, totalBytesWritten: Int64,                   totalBytesExpectedToWrite: Int64) {   // 1   baby-sit     let url = downloadTask.originalRequest?.url,     let download = downloadService.activeDownloads[url]       else {       return   }   // 2   download.progress =      Float(totalBytesWritten) / Bladder(totalBytesExpectedToWrite)   // three   let totalSize =      ByteCountFormatter.string(fromByteCount: totalBytesExpectedToWrite,                                countStyle: .file)    // iv   DispatchQueue.main.async {     if permit trackCell =        self.tableView.cellForRow(at: IndexPath(row: download.track.index,                                               department: 0)) equally? TrackCell {       trackCell.updateDisplay(progress: download.progress,                                totalSize: totalSize)     }   } }        

Looking through this delegate method, pace-by-step:

  1. You excerpt the URL of the provided downloadTask and use it to find the matching Download in your dictionary of active downloads.
  2. The method also provides the total bytes you have written and the total bytes you lot expect to write. You lot summate the progress equally the ratio of these 2 values and save the effect in Download. The runway prison cell will use this value to update the progress view.
  3. ByteCountFormatter takes a byte value and generates a human-readable string showing the total download file size. You'll employ this string to evidence the size of the download alongside the percentage complete.
  4. Finally, you find the cell responsible for displaying the Rails and call the jail cell's helper method to update its progress view and progress label with the values derived from the previous steps. This involves the UI, and then yous practise it on the chief queue.

Displaying the Download's Progress

Now, update the prison cell's configuration to brandish the progress view and status when a download is in progress.

Open TrackCell.swift. In configure(track:downloaded:download:), add together the following line inside the if-closure, after the pause button title is set up:

progressLabel.text = download.isDownloading ? "Downloading..." : "Paused"        

This gives the cell something to prove before the first update from the delegate method and while the download is paused.

Now, add the following code below the if-closure, below the isHidden lines for the two buttons:

progressView.isHidden = !showDownloadControls progressLabel.isHidden = !showDownloadControls        

This shows the progress view and label only while the download is in progress.

Build and run your project. Download any track and you should see the progress bar status update every bit the download progresses:

Half Tunes App with Download Progress Features

Hurray, you lot've made, erm, progress! :]

Enabling Background Transfers

Your app is quite functional at this bespeak, but at that place's i major enhancement left to add: Background transfers.

In this mode, downloads go along even when your app is in the background or if it crashes for whatever reason. This isn't really necessary for vocal snippets, which are pretty minor, but your users volition appreciate this feature if your app transfers large files.

Simply how tin this work if your app isn't running?

The OS runs a separate daemon exterior the app to manage groundwork transfer tasks, and it sends the appropriate delegate messages to the app as the download tasks run. In the consequence that the app terminates during an agile transfer, the tasks will proceed to run, unaffected, in the background.

When a task completes, the daemon will relaunch the app in the background. The relaunched app volition recreate the groundwork session to receive the relevant completion delegate letters and perform any required actions, such as persisting downloaded files to deejay.

Note: If the user terminates the app by force-quitting from the app switcher, the system will abolish all the session's background transfers and won't attempt to relaunch the app.

You'll access this magic by creating a session with the background session configuration.

In SearchViewController.swift, in the initialization of downloadsSession, observe the post-obit line of code:

permit configuration = URLSessionConfiguration.default        

…and supercede it with the following line:

let configuration =    URLSessionConfiguration.background(withIdentifier:                                        "com.raywenderlich.HalfTunes.bgSession")        

Instead of using a default session configuration, you'll employ a special background session configuration. Note that you lot also set a unique identifier for the session to allow your app to create a new background session, if needed.

Annotation: You lot must not create more than ane session for a groundwork configuration, because the system uses the configuration's identifier to associate tasks with the session.

Relaunching Your App

If a background task completes when the app isn't running, the app will relaunch in the background. Y'all'll need to handle this upshot from your app delegate.

Switch to AppDelegate.swift, supervene upon // TODO 17 with the following code:

var backgroundSessionCompletionHandler: (() -> Void)?        

Next, supplant // TODO 18 with the following method:

func awarding(   _ application: UIApplication,   handleEventsForBackgroundURLSession      handleEventsForBackgroundURLSessionidentifier: String,   completionHandler: @escaping () -> Void) {     backgroundSessionCompletionHandler = completionHandler }        

Hither, you save the provided completionHandler as a variable in your app delegate for later utilize.

application(_:handleEventsForBackgroundURLSession:) wakes upwards the app to deal with the completed groundwork chore. You'll need to handle two items in this method:

  • First, the app needs to recreate the appropriate background configuration and session using the identifier provided by this consul method. But since this app creates the groundwork session when information technology instantiates SearchViewController, you're already reconnected at this point!
  • Second, you'll demand to capture the completion handler provided past this delegate method. Invoking the completion handler tells the OS that your app's done working with all background activities for the current session. It also causes the OS to snapshot your updated UI for display in the app switcher.

The identify to invoke the provided completion handler is urlSessionDidFinishEvents(forBackgroundURLSession:), which is a URLSessionDelegate method that fires when all tasks on the background session have finished.

In SearchViewController.swift supercede // TODO xix with the following extension:

extension SearchViewController: URLSessionDelegate {   func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {     DispatchQueue.main.async {       if let appDelegate = UIApplication.shared.delegate as? AppDelegate,         let completionHandler = appDelegate.backgroundSessionCompletionHandler {         appDelegate.backgroundSessionCompletionHandler = nil                  completionHandler()       }     }   }  }        

The code above grabs the stored completion handler from the app delegate and invokes it on the main thread. Y'all find the app delegate by getting the shared instance of UIApplication, which is accessible thanks to the UIKit import.

Testing Your App's Functionality

Build and run your app. Offset a few concurrent downloads and tap the Dwelling button to transport the app to the background. Wait until you recall the downloads take completed, and so double-tap the Abode button to reveal the app switcher.

The downloads should accept finished, and you lot should encounter their new condition in the app snapshot. Open the app to ostend this:

Completed Half Tunes App With All Functions Enabled

Yous now take a functional music-streaming app! Your movement at present, Apple Music! :]

Where to Become From Here?

Congratulations! Yous're at present well-equipped to handle most common networking requirements in your app.

If you want to explore the field of study further, at that place are more URLSession topics than would fit in this tutorial. For example, you can also attempt out uploading tasks and session configuration settings such as timeout values and caching policies.

To acquire more about these features (and others!), check out the following resources:

  • Apple'southward URLSession Programming Guide contains comprehensive information on everything you'd want to do.
  • Our own Networking with URLSession video class starts with HTTP nuts, then goes on to cover tasks, groundwork sessions, authentication, App Send Security, architecture and unit testing.
  • AlamoFire is a popular third-party iOS networking library; nosotros cover the basics of it in our Beginning Alamofire tutorial.

I hope you enjoyed reading this tutorial. If yous have any questions or comments, please join the discussion below!

klingbeilnotho1963.blogspot.com

Source: https://www.raywenderlich.com/3244963-urlsession-tutorial-getting-started

0 Response to "Nsurlsessionconfiguration Background Upload on Cellular Not Working"

Publicar un comentario

Iklan Atas Artikel

Iklan Tengah Artikel 1

Iklan Tengah Artikel 2

Iklan Bawah Artikel