Commit e93cc2e9 authored by Vladislav Bogdashkin's avatar Vladislav Bogdashkin 🎣

downloader progress coding..

parent 4fdbf3bc
...@@ -2,8 +2,13 @@ ...@@ -2,8 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.biganto.visual.roompark"> package="com.biganto.visual.roompark">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application <application
android:name=".base.RoomParkApplication" android:name=".base.RoomParkApplication"
...@@ -58,6 +63,11 @@ ...@@ -58,6 +63,11 @@
android:resource="@color/colorAccent" /> android:resource="@color/colorAccent" />
</service> </service>
<service
android:name=".data.service.download.DownloadManagerService"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>
\ No newline at end of file
...@@ -60,7 +60,7 @@ class BigantoMviConductorLifecycleListener<V : MvpView, P : MviPresenter<V, *>> ...@@ -60,7 +60,7 @@ class BigantoMviConductorLifecycleListener<V : MvpView, P : MviPresenter<V, *>>
} }
val viewMpv = callback?.mvpView ?: throw NullPointerException( val viewMpv = callback?.mvpView ?: throw NullPointerException(
"MvpView returned from getMvpView() is null. Returned by " + controller.activity!!) "MvpView returned from getMvpView() is null. Returned by ${controller.activity}")
if (viewStateWillBeRestored) { if (viewStateWillBeRestored) {
callback!!.setRestoringViewState(true) callback!!.setRestoringViewState(true)
......
...@@ -60,12 +60,18 @@ class BigantoRetrofitRepository @Inject constructor(@Named("bigantoApi") retrof ...@@ -60,12 +60,18 @@ class BigantoRetrofitRepository @Inject constructor(@Named("bigantoApi") retrof
.doOnError(::e) .doOnError(::e)
override fun getOfferTours(multiTourId:Int): Observable<List<TourPreviewRaw>> = override fun getOfferTours(multiTourIds:List<Int>): Observable<List<TourPreviewRaw>> =
api api
.getOfferTours(offerId = multiTourId) .getOfferTours(offerId = multiTourIds)
.compose(RetrofitResponseValidation()) .compose(RetrofitResponseValidation())
.map { it.values.flatten() }
.doOnError { e(it) }
.subscribeOn(Schedulers.io())
override fun getOfferTours(multiTourId:Int): Observable<List<TourPreviewRaw>> =
api
.getOfferTours(offerId = arrayListOf(multiTourId))
.compose(RetrofitResponseValidation())
.map { it[multiTourId.toString()]?.toList()?: error("No tours avaliable")} .map { it[multiTourId.toString()]?.toList()?: error("No tours avaliable")}
.doOnError { e(it) } .doOnError { e(it) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
......
...@@ -25,4 +25,5 @@ interface IBigantoApi { ...@@ -25,4 +25,5 @@ interface IBigantoApi {
fun getToursPreviewById(tourIds: List<String>): Observable<List<TourPreviewRaw>> fun getToursPreviewById(tourIds: List<String>): Observable<List<TourPreviewRaw>>
fun getOfferTours(multiTourId:Int): Observable<List<TourPreviewRaw>> fun getOfferTours(multiTourId:Int): Observable<List<TourPreviewRaw>>
fun getOfferTours(multiTourIds: List<Int>): Observable<List<TourPreviewRaw>>
} }
\ No newline at end of file
...@@ -145,7 +145,7 @@ interface IBigantoMobileApi { ...@@ -145,7 +145,7 @@ interface IBigantoMobileApi {
@Query(CLIENT_VERSION_PARAM) clientVersion: String = DEFAULT_CLIENT_VERSION, @Query(CLIENT_VERSION_PARAM) clientVersion: String = DEFAULT_CLIENT_VERSION,
@Query(API_VERSION_PARAM) apiVersion: String = DEFAULT_API_VERSION, @Query(API_VERSION_PARAM) apiVersion: String = DEFAULT_API_VERSION,
@Query(LANG_PARAM) languageCode: String = Locale.getDefault().language, @Query(LANG_PARAM) languageCode: String = Locale.getDefault().language,
@Query(OFFER_GET_TOURS_ID) offerId: Int @Query(OFFER_GET_TOURS_ID) offerId: List<Int>
): Observable<Response<Map<String,List<TourPreviewRaw>>>> ): Observable<Response<Map<String,List<TourPreviewRaw>>>>
@Streaming @Streaming
......
...@@ -36,7 +36,7 @@ private const val TIMEOUT_SECONDS=120L ...@@ -36,7 +36,7 @@ private const val TIMEOUT_SECONDS=120L
private const val WRITE_SECONDS=120L private const val WRITE_SECONDS=120L
private const val READ_SECONDS=120L private const val READ_SECONDS=120L
val INTERCEPT_LOG_LEVEL = HttpLoggingInterceptor.Level.BODY val INTERCEPT_LOG_LEVEL = HttpLoggingInterceptor.Level.NONE
@Module @Module
class RetrofitModule{ class RetrofitModule{
......
...@@ -17,7 +17,7 @@ import com.biganto.visual.roompark.data.repository.db.requrey.model.fromRaw ...@@ -17,7 +17,7 @@ import com.biganto.visual.roompark.data.repository.db.requrey.model.fromRaw
import com.biganto.visual.roompark.data.repository.file.FileModule import com.biganto.visual.roompark.data.repository.file.FileModule
import com.biganto.visual.roompark.data.service.lifecycle.ForegroundLifecycleObserver import com.biganto.visual.roompark.data.service.lifecycle.ForegroundLifecycleObserver
import com.biganto.visual.roompark.data.service.notification.INotificationCenter import com.biganto.visual.roompark.data.service.notification.INotificationCenter
import com.biganto.visual.roompark.data.service.notification.NotificationCenter import com.biganto.visual.roompark.domain.use_case.TOUR_IDS_TO_DOWNLOAD_KEY
import com.biganto.visual.roomparkvr.data.repository.db.requery.model.DownloadState import com.biganto.visual.roomparkvr.data.repository.db.requery.model.DownloadState
import com.biganto.visual.roomparkvr.data.repository.db.requery.model.TourPreviewEntity import com.biganto.visual.roomparkvr.data.repository.db.requery.model.TourPreviewEntity
import com.jakewharton.rxrelay2.PublishRelay import com.jakewharton.rxrelay2.PublishRelay
...@@ -41,7 +41,7 @@ import javax.inject.Singleton ...@@ -41,7 +41,7 @@ import javax.inject.Singleton
*/ */
private const val DB_ACCESS_CHUNK_SIZE = 256 private const val DB_ACCESS_CHUNK_SIZE = 256
private const val READ_SYNC_MILLS = 64L private const val READ_SYNC_MILLS = 12L
private const val DEQUE_REQUEST_TIMEOUT_MILLS=100L private const val DEQUE_REQUEST_TIMEOUT_MILLS=100L
private const val META_PREDICTION="/tourMeta_" private const val META_PREDICTION="/tourMeta_"
private const val META_FILE_TYPE=".json" private const val META_FILE_TYPE=".json"
...@@ -127,7 +127,11 @@ class DownloadManagerService @Inject constructor( ...@@ -127,7 +127,11 @@ class DownloadManagerService @Inject constructor(
if (disposable.size() == 0) if (disposable.size() == 0)
attachDownloader() attachDownloader()
return Service.START_NOT_STICKY
intent?.getStringArrayListExtra(TOUR_IDS_TO_DOWNLOAD_KEY)
?.forEach { toursToDownloadObserver.accept(it) }
return START_NOT_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
...@@ -263,7 +267,9 @@ class DownloadManagerService @Inject constructor( ...@@ -263,7 +267,9 @@ class DownloadManagerService @Inject constructor(
val fileEntities = raw.files.map(::fromRaw) val fileEntities = raw.files.map(::fromRaw)
mergeFiles(fileEntities) mergeFiles(fileEntities)
Timber.d("1")
val jlist = db.getTourFilesJunction(tour.id).toList() val jlist = db.getTourFilesJunction(tour.id).toList()
Timber.d("2")
val junctionList = fileEntities val junctionList = fileEntities
.map {file -> .map {file ->
val entity = jlist.firstOrNull{it.tour == tour.id && it.file == file.uri} val entity = jlist.firstOrNull{it.tour == tour.id && it.file == file.uri}
...@@ -275,6 +281,7 @@ class DownloadManagerService @Inject constructor( ...@@ -275,6 +281,7 @@ class DownloadManagerService @Inject constructor(
entity entity
} }
Timber.d("3")
setDownloadInfo( setDownloadInfo(
raw.id.toString() raw.id.toString()
, downloadedSize = downloadedSize , downloadedSize = downloadedSize
...@@ -283,7 +290,9 @@ class DownloadManagerService @Inject constructor( ...@@ -283,7 +290,9 @@ class DownloadManagerService @Inject constructor(
, filesCount = raw.files.count() , filesCount = raw.files.count()
).map { junctionList } ).map { junctionList }
} }
.doOnNext{Timber.d("4")}
.flatMap{ db.upsertTourFileJunction(it) } .flatMap{ db.upsertTourFileJunction(it) }
.doOnNext{Timber.d("5 ${it.count()}")}
.flatMapIterable { it } .flatMapIterable { it }
.flatMap { junction -> .flatMap { junction ->
db.getFileEntity(junction.file) db.getFileEntity(junction.file)
...@@ -326,7 +335,9 @@ class DownloadManagerService @Inject constructor( ...@@ -326,7 +335,9 @@ class DownloadManagerService @Inject constructor(
.map { downloadInfo } .map { downloadInfo }
} }
.sequential() .sequential()
.toObservable()
.observeOn(Schedulers.computation()) .observeOn(Schedulers.computation())
// .doOnNext{Timber.d("7 ${it}")}
.map { model -> .map { model ->
setDownloadInfo( setDownloadInfo(
model.tourId model.tourId
...@@ -338,7 +349,8 @@ class DownloadManagerService @Inject constructor( ...@@ -338,7 +349,8 @@ class DownloadManagerService @Inject constructor(
model.tourId model.tourId
} }
.delay(12L, TimeUnit.MILLISECONDS) .delay(12L, TimeUnit.MILLISECONDS)
.toObservable()
private val checkService = private val checkService =
Observable.interval(0L, DEQUE_REQUEST_TIMEOUT_MILLS, TimeUnit.MILLISECONDS) Observable.interval(0L, DEQUE_REQUEST_TIMEOUT_MILLS, TimeUnit.MILLISECONDS)
...@@ -351,6 +363,7 @@ class DownloadManagerService @Inject constructor( ...@@ -351,6 +363,7 @@ class DownloadManagerService @Inject constructor(
deleteTourFromQueue() deleteTourFromQueue()
} }
downloadQueue.isNotEmpty() -> { downloadQueue.isNotEmpty() -> {
Timber.w(" 8 to donload: ${downloadQueue.size}")
notifyDownloading() notifyDownloading()
downloadTourFromQueue() downloadTourFromQueue()
} }
...@@ -363,7 +376,9 @@ class DownloadManagerService @Inject constructor( ...@@ -363,7 +376,9 @@ class DownloadManagerService @Inject constructor(
private fun downloadTourFromQueue(): Observable<String> = private fun downloadTourFromQueue(): Observable<String> =
Observable.fromCallable { activeDownloading.set(true);downloadQueue.poll() } Observable.fromCallable { activeDownloading.set(true);downloadQueue.poll() }
.doOnNext { Timber.w("loading tour $it") }
.flatMap { db.getTourPreview(it).observable() } .flatMap { db.getTourPreview(it).observable() }
.doOnNext { Timber.w("loading tour status ${it.isDownloaded}") }
.filter { .filter {
val forward = it.isDownloaded == DownloadState.Downloading val forward = it.isDownloaded == DownloadState.Downloading
activeDownloading.set(forward) activeDownloading.set(forward)
......
package com.biganto.visual.roompark.domain.interactor package com.biganto.visual.roompark.domain.interactor
import android.content.Context import android.content.Intent
import android.os.Build
import com.biganto.visual.roompark.R import com.biganto.visual.roompark.R
import com.biganto.visual.roompark.base.BaseRoomParkActivity
import com.biganto.visual.roompark.data.service.download.DownloadManagerService
import com.biganto.visual.roompark.domain.model.CachedDataModel import com.biganto.visual.roompark.domain.model.CachedDataModel
import com.biganto.visual.roompark.domain.model.PushSwitchModel import com.biganto.visual.roompark.domain.model.PushSwitchModel
import com.biganto.visual.roompark.domain.model.SettingsModel import com.biganto.visual.roompark.domain.model.SettingsModel
import com.biganto.visual.roompark.domain.model.SubscriptionModel import com.biganto.visual.roompark.domain.model.SubscriptionModel
import com.biganto.visual.roompark.domain.use_case.AuthUseCase import com.biganto.visual.roompark.domain.use_case.*
import com.biganto.visual.roompark.domain.use_case.SettingsUseCase
import com.biganto.visual.roompark.domain.use_case.SubscriptionUseCase
import io.reactivex.Completable import io.reactivex.Completable
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
...@@ -21,10 +23,35 @@ import javax.inject.Inject ...@@ -21,10 +23,35 @@ import javax.inject.Inject
class SettingsInteractor @Inject constructor( class SettingsInteractor @Inject constructor(
private val auth: AuthUseCase, private val auth: AuthUseCase,
private val settingsUseCase: SettingsUseCase, private val settingsUseCase: SettingsUseCase,
private val activity: Context, private val activity: BaseRoomParkActivity,
private val subUc: SubscriptionUseCase private val subUc: SubscriptionUseCase,
private val toursUc: TourPreviewsUseCase
){ ){
private fun startDownloadService(ids:List<String>){
Timber.d(" gonna startService ++")
try {
val i = Intent(activity, DownloadManagerService::class.java)
i.putStringArrayListExtra(TOUR_IDS_TO_DOWNLOAD_KEY, ArrayList(ids))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(i)
} else {
activity.startService(i)
}
}catch (e:Exception){
Timber.d(" gonna startService errror")
Timber.e(e)
}
}
fun startToursDownloading() =
toursUc.downloadAllDeelsAndEstates()
.doOnNext { Timber.d(" gonna startService") }
.doOnNext { tours ->
startDownloadService(tours.map { tour -> tour.id })
}.ignoreElements()
fun getSubscriptions() = fun getSubscriptions() =
subUc.getCurrentUserSubscriptions() subUc.getCurrentUserSubscriptions()
......
...@@ -11,6 +11,7 @@ import com.biganto.visual.roompark.data.repository.db.requrey.model.TourFileJunc ...@@ -11,6 +11,7 @@ import com.biganto.visual.roompark.data.repository.db.requrey.model.TourFileJunc
import com.biganto.visual.roompark.data.repository.db.requrey.model.fromRaw import com.biganto.visual.roompark.data.repository.db.requrey.model.fromRaw
import com.biganto.visual.roompark.data.repository.file.FileModule import com.biganto.visual.roompark.data.repository.file.FileModule
import com.biganto.visual.roompark.data.service.download.TourFileData import com.biganto.visual.roompark.data.service.download.TourFileData
import com.biganto.visual.roompark.domain.contract.AuthContract
import com.biganto.visual.roomparkvr.data.repository.db.requery.model.DownloadState import com.biganto.visual.roomparkvr.data.repository.db.requery.model.DownloadState
import com.biganto.visual.roomparkvr.data.repository.db.requery.model.TourPreviewEntity import com.biganto.visual.roomparkvr.data.repository.db.requery.model.TourPreviewEntity
import io.reactivex.BackpressureStrategy import io.reactivex.BackpressureStrategy
...@@ -37,9 +38,12 @@ class DownloadUseCase @Inject constructor( ...@@ -37,9 +38,12 @@ class DownloadUseCase @Inject constructor(
private val db: IDb, private val db: IDb,
private val api: IBigantoApi, private val api: IBigantoApi,
private val fileModule: FileModule, private val fileModule: FileModule,
private val auth: AuthContract,
private val context: Application private val context: Application
) { ) {
private fun writeFile( private fun writeFile(
response: ResponseBody, response: ResponseBody,
model: TourFileData, model: TourFileData,
...@@ -111,7 +115,7 @@ class DownloadUseCase @Inject constructor( ...@@ -111,7 +115,7 @@ class DownloadUseCase @Inject constructor(
} }
Timber.d("save files: ${files.size}") Timber.d("save files: ${files.size}")
db.upsertFileEntity(files)?.blockingSubscribe { Timber.d("file saved") } db.upsertFileEntity(files).blockingSubscribe { Timber.d("file saved") }
} }
@Volatile @Volatile
......
...@@ -21,6 +21,7 @@ import javax.inject.Inject ...@@ -21,6 +21,7 @@ import javax.inject.Inject
*/ */
const val EMPTY_PARENT = -778 const val EMPTY_PARENT = -778
const val TOUR_IDS_TO_DOWNLOAD_KEY = "DOWNLOAD_MANAGER_IDS_TO LOAD"
class TourPreviewsUseCase @Inject constructor( class TourPreviewsUseCase @Inject constructor(
private val api:IBigantoApi private val api:IBigantoApi
...@@ -54,7 +55,7 @@ class TourPreviewsUseCase @Inject constructor( ...@@ -54,7 +55,7 @@ class TourPreviewsUseCase @Inject constructor(
db.getTourPreview(tourId) db.getTourPreview(tourId)
.observable() .observable()
.doOnNext{ it.isDownloaded=state } .doOnNext{ it.isDownloaded=state }
.flatMap { db.upsertTourPreview(it)?.map{true} } .flatMap { db.upsertTourPreview(it).map{true} }
private fun syncTour(tourId:String, parentId:Int):Observable<TourModel> = private fun syncTour(tourId:String, parentId:Int):Observable<TourModel> =
syncTour(tourId,parentId,calcTargetResolution()) syncTour(tourId,parentId,calcTargetResolution())
...@@ -168,6 +169,46 @@ class TourPreviewsUseCase @Inject constructor( ...@@ -168,6 +169,46 @@ class TourPreviewsUseCase @Inject constructor(
return fromApi return fromApi
} }
fun downloadAllDeelsAndEstates(): Observable<Iterable<TourPreviewEntity>> =
auth.currentUser()
.map { user ->
user.deals
?.asSequence()
?.filter { it.estate.multitourId != null }
?.toList()
?.map {
Pair(
it.estate.multitourId!!
, TourRemoteRequestModel(
estateId = it.estate.id
, targetResolution = user.targetResolution
)
)
}
}
.doOnNext { Timber.d("merged list:${it?.size}") }
.map { list ->
list.map { pair ->
api.getOfferTours(pair.first)
.doOnNext { Timber.d(" gonna merge") }
.map {
mergeRaw(
it
, pair.second
)
}
.map { it }
.doOnNext { Timber.d("merged list:${it.size}") }
.blockingFirst()
}
.flatten()
}
// .map { it.flatten() }
.doOnNext { Timber.d("merged flatten list:${it.size}") }
.flatMap { db.upsertTourPreview(it) }
.subscribeOn(Schedulers.io())
} }
data class TourRemoteRequestModel(val estateId: Int, val token: String? = null, val targetResolution: Int) data class TourRemoteRequestModel(val estateId: Int, val token: String? = null, val targetResolution: Int)
......
...@@ -9,6 +9,7 @@ import io.reactivex.Observable ...@@ -9,6 +9,7 @@ import io.reactivex.Observable
*/ */
interface SettingsScreen : BigantoBaseContract<SettingsScreenViewState> { interface SettingsScreen : BigantoBaseContract<SettingsScreenViewState> {
fun downloadAllTours(): Observable<Int>
fun signOut(): Observable<Int> fun signOut(): Observable<Int>
fun clearCache(): Observable<Int> fun clearCache(): Observable<Int>
fun refreshCacheInfo(): Observable<Int> fun refreshCacheInfo(): Observable<Int>
......
...@@ -51,6 +51,10 @@ class SettingsScreenController : ...@@ -51,6 +51,10 @@ class SettingsScreenController :
.map { Timber.d("Clicked clear cache button"); 1 } .map { Timber.d("Clicked clear cache button"); 1 }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
override fun downloadAllTours(): Observable<Int> =
toursDownloaderButton.clicks().map { 1 }.observeOn(AndroidSchedulers.mainThread())
private val refreshEmitter = BehaviorRelay.create<Int>() private val refreshEmitter = BehaviorRelay.create<Int>()
override fun refreshCacheInfo(): Observable<Int> = refreshEmitter override fun refreshCacheInfo(): Observable<Int> = refreshEmitter
......
package com.biganto.visual.roompark.presentation.screen.settings package com.biganto.visual.roompark.presentation.screen.settings
import android.content.Context import com.biganto.visual.roompark.base.BaseRoomParkActivity
import com.biganto.visual.roompark.base.RoomParkMainActivity import com.biganto.visual.roompark.base.RoomParkMainActivity
import com.biganto.visual.roompark.di.dagger.AppComponent import com.biganto.visual.roompark.di.dagger.AppComponent
import com.biganto.visual.roompark.di.dagger.PerScreen import com.biganto.visual.roompark.di.dagger.PerScreen
...@@ -33,7 +33,7 @@ abstract class SettingsScreenModule{ ...@@ -33,7 +33,7 @@ abstract class SettingsScreenModule{
@PerScreen @PerScreen
@Binds @Binds
abstract fun provideContext(activity: RoomParkMainActivity): Context abstract fun provideActivity(activity: RoomParkMainActivity): BaseRoomParkActivity
// @PerScreen // @PerScreen
// @Binds // @Binds
......
package com.biganto.visual.roompark.presentation.screen.settings package com.biganto.visual.roompark.presentation.screen.settings
import android.content.Context import com.biganto.visual.roompark.base.BaseRoomParkActivity
import com.biganto.visual.roompark.conductor.BigantoBasePresenter import com.biganto.visual.roompark.conductor.BigantoBasePresenter
import com.biganto.visual.roompark.domain.interactor.SettingsInteractor import com.biganto.visual.roompark.domain.interactor.SettingsInteractor
import com.biganto.visual.roompark.domain.model.CachedDataModel import com.biganto.visual.roompark.domain.model.CachedDataModel
...@@ -21,7 +21,7 @@ import javax.inject.Inject ...@@ -21,7 +21,7 @@ import javax.inject.Inject
class SettingsScreenPresenter @Inject constructor( class SettingsScreenPresenter @Inject constructor(
private val interactor: SettingsInteractor, private val interactor: SettingsInteractor,
private val activity: Context private val activity: BaseRoomParkActivity
) )
: BigantoBasePresenter<SettingsScreen, SettingsScreenViewState>() { : BigantoBasePresenter<SettingsScreen, SettingsScreenViewState>() {
...@@ -38,6 +38,10 @@ class SettingsScreenPresenter @Inject constructor( ...@@ -38,6 +38,10 @@ class SettingsScreenPresenter @Inject constructor(
override fun bindIntents() { override fun bindIntents() {
val onDownloadTours = intent(SettingsScreen::downloadAllTours)
.flatMap { interactor.startToursDownloading()
.andThen(Observable.just(SettingsScreenViewState.Idle()))
}
val onSubChecked = intent(SettingsScreen::onSubscription) val onSubChecked = intent(SettingsScreen::onSubscription)
.flatMap { sub -> .flatMap { sub ->
...@@ -115,7 +119,8 @@ class SettingsScreenPresenter @Inject constructor( ...@@ -115,7 +119,8 @@ class SettingsScreenPresenter @Inject constructor(
refreshInfo, refreshInfo,
fetchSubscriptions, fetchSubscriptions,
fetchCache, fetchCache,
onSubChecked onSubChecked,
onDownloadTours
) )
) )
.doOnError { Timber.e(it) } .doOnError { Timber.e(it) }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment