Commit 4386af90 authored by Konstantin Tskhovrebov's avatar Konstantin Tskhovrebov 🤖

Merge branch 'develop'

parents ba3edc21 649ebfd4
......@@ -15,8 +15,8 @@ android {
minSdkVersion 19
targetSdkVersion 28
versionName "1.0.2"
versionCode 4
versionName "1.1"
versionCode 5
buildConfigField "String", "VERSION_UID", '"' + getBuildUid() + '"'
buildConfigField "String", "APP_DESCRIPTION", '"Gitfox is an Android client for Gitlab."'
......@@ -69,7 +69,7 @@ android {
}
ext {
supportLibraryVersion = "28.0.0-rc02"
supportLibraryVersion = "28.0.0"
moxyVersion = "1.4.6"
toothpickVersion = "1.0.6"
retrofitVersion = "2.2.0"
......
......@@ -40,12 +40,13 @@ class App : Application() {
}
private fun initFabric() {
Fabric.with(
Fabric.Builder(this)
.kits(Crashlytics())
.debuggable(BuildConfig.DEBUG)
.build()
)
if (!BuildConfig.DEBUG) {
Fabric.with(
Fabric.Builder(this)
.kits(Crashlytics())
.build()
)
}
}
private fun initToothpick() {
......
......@@ -26,9 +26,12 @@ import ru.terrakok.gitlabclient.ui.my.mergerequests.MyMergeRequestsContainerFrag
import ru.terrakok.gitlabclient.ui.my.mergerequests.MyMergeRequestsFragment
import ru.terrakok.gitlabclient.ui.my.todos.MyTodosContainerFragment
import ru.terrakok.gitlabclient.ui.my.todos.MyTodosFragment
import ru.terrakok.gitlabclient.ui.privacypolicy.PrivacyPolicyFragment
import ru.terrakok.gitlabclient.ui.project.ProjectFlowFragment
import ru.terrakok.gitlabclient.ui.project.ProjectFragment
import ru.terrakok.gitlabclient.ui.project.ProjectInfoFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectEventsFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectInfoContainerFragment
import ru.terrakok.gitlabclient.ui.project.info.ProjectInfoFragment
import ru.terrakok.gitlabclient.ui.project.issues.ProjectIssuesContainerFragment
import ru.terrakok.gitlabclient.ui.project.issues.ProjectIssuesFragment
import ru.terrakok.gitlabclient.ui.project.mergerequest.ProjectMergeRequestsContainerFragment
......@@ -68,7 +71,9 @@ object Screens {
const val PROJECT_FLOW = "project flow"
const val PROJECT_MAIN_FLOW = "project main flow"
const val PROJECT_INFO_CONTAINER_SCREEN = "project info container screen"
const val PROJECT_INFO_SCREEN = "project info screen"
const val PROJECT_EVENTS_SCREEN = "project events screen"
const val PROJECT_ISSUES_CONTAINER_SCREEN = "project issues container screen"
const val PROJECT_ISSUES_SCREEN = "project issues screen"
const val PROJECT_MR_CONTAINER_SCREEN = "project mr container screen"
......@@ -90,6 +95,7 @@ object Screens {
const val EXTERNAL_BROWSER_FLOW = "external browser flow"
const val SHARE_FLOW = "share flow"
const val CALL_FLOW = "call flow"
const val PRIVACY_POLICY_FLOW = "privacy policy flow"
fun createIntent(flowKey: String, data: Any?) = when (flowKey) {
Screens.EXTERNAL_BROWSER_FLOW -> Intent(Intent.ACTION_VIEW, Uri.parse(data as String))
......@@ -141,9 +147,14 @@ object Screens {
Screens.PROJECT_FLOW -> ProjectFlowFragment.create(data as Long)
Screens.PROJECT_MAIN_FLOW -> ProjectFragment()
Screens.PROJECT_INFO_CONTAINER_SCREEN -> ProjectInfoContainerFragment()
Screens.PROJECT_INFO_SCREEN -> ProjectInfoFragment()
Screens.PROJECT_EVENTS_SCREEN -> ProjectEventsFragment()
Screens.PROJECT_ISSUES_CONTAINER_SCREEN -> ProjectIssuesContainerFragment()
Screens.PROJECT_ISSUES_SCREEN -> ProjectIssuesFragment.create(data as IssueState)
Screens.PROJECT_MR_CONTAINER_SCREEN -> ProjectMergeRequestsContainerFragment()
Screens.PROJECT_MR_SCREEN -> ProjectMergeRequestsFragment.create(data as MergeRequestState)
......@@ -162,6 +173,8 @@ object Screens {
Screens.ISSUE_SCREEN -> IssueFragment()
Screens.ISSUE_INFO_SCREEN -> IssueInfoFragment()
Screens.ISSUE_NOTES_SCREEN -> IssueNotesFragment()
Screens.PRIVACY_POLICY_FLOW -> PrivacyPolicyFragment()
else -> null
}
}
\ No newline at end of file
......@@ -10,7 +10,8 @@ data class Milestone(
@SerializedName("description") val description: String?,
@SerializedName("state") val state: String?,
@SerializedName("due_date") val dueDate: String?,
@SerializedName("start_date") val startDate: String?,
@SerializedName("created_at") val createdAt: LocalDateTime?,
@SerializedName("title") val title: String?,
@SerializedName("updated_at") val updatedAt: String?
@SerializedName("updated_at") val updatedAt: LocalDateTime?
)
......@@ -10,7 +10,7 @@ data class Issue(
@SerializedName("id") val id: Long,
@SerializedName("iid") val iid: Long,
@SerializedName("state") val state: IssueState,
@SerializedName("description") val description: String?,
@SerializedName("description") val description: String,
@SerializedName("author") val author: Author,
@SerializedName("milestone") val milestone: Milestone?,
@SerializedName("project_id") val projectId: Long,
......@@ -20,7 +20,7 @@ data class Issue(
@SerializedName("created_at") val createdAt: LocalDateTime,
@SerializedName("labels") val labels: List<String>,
@SerializedName("user_notes_count") val userNotesCount: Int,
@SerializedName("due_date") val dueDate: LocalDateTime?,
@SerializedName("due_date") val dueDate: String?,
@SerializedName("web_url") val webUrl: String?,
@SerializedName("confidential") val confidential: Boolean,
@SerializedName("upvotes") val upvotes: Int,
......
......@@ -22,7 +22,7 @@ data class MergeRequest(
@SerializedName("assignee") val assignee: User?,
@SerializedName("source_project_id") val sourceProjectId: Int,
@SerializedName("target_project_id") val targetProjectId: Int,
@SerializedName("description") val description: String?,
@SerializedName("description") val description: String,
@SerializedName("work_in_progress") val workInProgress: Boolean,
@SerializedName("milestone") val milestone: Milestone?,
@SerializedName("merge_when_pipeline_succeeds") val mergeWhenPipelineSucceeds: Boolean,
......
......@@ -124,6 +124,19 @@ interface GitlabApi {
@Query("per_page") pageSize: Int
): Single<List<Event>>
@GET("$API_PATH/projects/{project_id}/events")
fun getProjectEvents(
@Path("project_id") projectId: Long,
@Query("action") action: EventAction?,
@Query("target_type") targetType: EventTarget?,
@Query("before") beforeDay: String?,
@Query("after") afterDay: String?,
@Query("sort") sort: Sort?,
@Query("order_by") orderBy: OrderBy?,
@Query("page") page: Int,
@Query("per_page") pageSize: Int
): Single<List<Event>>
@GET("$API_PATH/merge_requests")
fun getMyMergeRequests(
@Query("state") state: MergeRequestState?,
......
......@@ -8,20 +8,13 @@ import javax.inject.Inject
*/
class MarkDownUrlResolver @Inject constructor() {
private val regex = Regex("^!\\[.+]\\(/uploads/.+/.+\\.\\w{3,4}\\)$")
// ![CragHag](/uploads/69c4ef83b86c66eb3f147915d26c427e/CragHag.png) - before attach
// ![2018-09-03_15.26.43](/uploads/c1fc914375a3d975f12bb6d54d1ee8c8/2018-09-03_15.26.43.jpg)
// https://gitlab.com/terrakok/gitlab-client/uploads/b4048510da2ba117cdc793007066bc25/CragHag.png link to download
// https://gitlab.com/CraggyHaggy/GandastBot/uploads/9475c799f5e9ad5cd1a8ce28ce652ff9/citadel.jpg
private val regex = Regex("!\\[[^]]+]\\(/uploads/[^)]+\\)")
fun resolve(body: String, project: Project): String {
return if (regex.matches(body)) {
StringBuilder(body)
.insert(body.indexOf("/uploads/"), project.pathWithNamespace)
.toString()
} else {
body
val builderBody = StringBuilder(body)
regex.findAll(body).forEach {
builderBody.insert(builderBody.indexOf("/uploads/", it.range.start), project.pathWithNamespace)
}
return builderBody.toString()
}
}
\ No newline at end of file
......@@ -8,7 +8,6 @@ import org.threeten.bp.ZonedDateTime
import java.lang.reflect.Type
import java.util.*
/**
* Created by Konstantin Tskhovrebov (aka @terrakok) on 27.02.18.
*/
......
......@@ -17,6 +17,9 @@ class Prefs @Inject constructor(
private val KEY_SERVER_PATH = "ad_server_path"
private val KEY_IS_OAUTH = "ad_is_oauth"
private val APP_DATA = "app_data"
private val KEY_FIRST_LAUNCH_TIME = "launch_ts"
private fun getSharedPreferences(prefsName: String) = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
override var token: String?
......@@ -36,4 +39,10 @@ class Prefs @Inject constructor(
set(value) {
getSharedPreferences(AUTH_DATA).edit().putBoolean(KEY_IS_OAUTH, value).apply()
}
var firstLaunchTimeStamp: Long?
get() = getSharedPreferences(APP_DATA).getLong(KEY_FIRST_LAUNCH_TIME, 0).takeIf { it > 0 }
set(value) {
getSharedPreferences(APP_DATA).edit().putLong(KEY_FIRST_LAUNCH_TIME, value ?: 0).apply()
}
}
\ No newline at end of file
......@@ -9,5 +9,9 @@ import javax.inject.Inject
class EventInteractor @Inject constructor(
private val eventRepository: EventRepository
) {
fun getEvents(page: Int) = eventRepository.getEvents(page = page)
fun getEvents(page: Int) =
eventRepository.getEvents(page = page)
fun getProjectEvents(projectId: Long, page: Int) =
eventRepository.getProjectEvents(projectId = projectId, page = page)
}
\ No newline at end of file
package ru.terrakok.gitlabclient.model.interactor.launch
import ru.terrakok.gitlabclient.model.repository.app.AppInfoRepository
import javax.inject.Inject
/**
* Created by Konstantin Tskhovrebov (aka @terrakok) on 26.09.18.
*/
class LaunchInteractor @Inject constructor(
private val appInfoRepository: AppInfoRepository
) {
val isFirstLaunch: Boolean
get() {
val timeStamp = appInfoRepository.firstLaunchTimeStamp
if (timeStamp == null) {
appInfoRepository.firstLaunchTimeStamp = System.currentTimeMillis()
return true
} else {
return false
}
}
}
\ No newline at end of file
......@@ -2,6 +2,7 @@ package ru.terrakok.gitlabclient.model.repository.app
import io.reactivex.Single
import ru.terrakok.gitlabclient.entity.app.develop.AppInfo
import ru.terrakok.gitlabclient.model.data.storage.Prefs
import ru.terrakok.gitlabclient.model.data.storage.RawAppData
import ru.terrakok.gitlabclient.model.system.SchedulersProvider
import javax.inject.Inject
......@@ -12,9 +13,16 @@ import javax.inject.Inject
class AppInfoRepository @Inject constructor(
private val rawAppData: RawAppData,
private val appInfo: AppInfo,
private val prefs: Prefs,
private val schedulers: SchedulersProvider
) {
var firstLaunchTimeStamp: Long?
get() = prefs.firstLaunchTimeStamp
set(value) {
prefs.firstLaunchTimeStamp = value
}
fun getAppInfo() = Single.just(appInfo)
fun getAppLibraries() = rawAppData
......
......@@ -14,8 +14,8 @@ import ru.terrakok.gitlabclient.entity.event.EventAction
import ru.terrakok.gitlabclient.entity.event.EventTarget
import ru.terrakok.gitlabclient.entity.event.EventTargetType
import ru.terrakok.gitlabclient.model.data.server.GitlabApi
import ru.terrakok.gitlabclient.model.system.SchedulersProvider
import ru.terrakok.gitlabclient.model.data.server.MarkDownUrlResolver
import ru.terrakok.gitlabclient.model.system.SchedulersProvider
import ru.terrakok.gitlabclient.toothpick.PrimitiveWrapper
import ru.terrakok.gitlabclient.toothpick.qualifier.DefaultPageSize
import javax.inject.Inject
......@@ -63,6 +63,40 @@ class EventRepository @Inject constructor(
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
fun getProjectEvents(
projectId: Long,
action: EventAction? = null,
targetType: EventTarget? = null,
beforeDay: LocalDateTime? = null,
afterDay: LocalDateTime? = null,
sort: Sort? = Sort.DESC,
orderBy: OrderBy = OrderBy.UPDATED_AT,
page: Int,
pageSize: Int = defaultPageSize
): Single<List<TargetHeader>> = api
.getProjectEvents(
projectId,
action,
targetType,
beforeDay?.run { this.toLocalDate().toString() },
afterDay?.run { this.toLocalDate().toString() },
sort,
orderBy,
page,
pageSize
)
.flatMap { events ->
Single.zip(
Single.just(events),
getDistinctProjects(events),
BiFunction<List<Event>, Map<Long, Project>, List<TargetHeader>> { sourceEvents, projects ->
sourceEvents.map { getTargetHeader(it, projects[it.projectId]) }
}
)
}
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
private fun getDistinctProjects(events: List<Event>): Single<Map<Long, Project>> {
return Observable.fromIterable(events)
.distinct { it.projectId }
......
......@@ -126,8 +126,19 @@ class IssueRepository @Inject constructor(
fun getIssue(
projectId: Long,
issueId: Long
) = api
.getIssue(projectId, issueId)
) = Single
.zip(
api.getProject(projectId),
api.getIssue(projectId, issueId),
BiFunction<Project, Issue, Issue> { project, issue ->
val resolved = markDownUrlResolver.resolve(issue.description, project)
if (resolved != issue.description) {
issue.copy(description = resolved)
} else {
issue
}
}
)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
......
......@@ -140,8 +140,19 @@ class MergeRequestRepository @Inject constructor(
fun getMergeRequest(
projectId: Long,
mergeRequestId: Long
) = api
.getMergeRequest(projectId, mergeRequestId)
) = Single
.zip(
api.getProject(projectId),
api.getMergeRequest(projectId, mergeRequestId),
BiFunction<Project, MergeRequest, MergeRequest> { project, mr ->
val resolved = markDownUrlResolver.resolve(mr.description, project)
if (resolved != mr.description) {
mr.copy(description = resolved)
} else {
mr
}
}
)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
......
......@@ -5,6 +5,7 @@ import com.arellomobile.mvp.MvpView
import ru.terrakok.cicerone.Router
import ru.terrakok.gitlabclient.Screens
import ru.terrakok.gitlabclient.model.interactor.auth.AuthInteractor
import ru.terrakok.gitlabclient.model.interactor.launch.LaunchInteractor
import javax.inject.Inject
/**
......@@ -12,11 +13,19 @@ import javax.inject.Inject
*/
class AppPresenter @Inject constructor(
private val router: Router,
private val authInteractor: AuthInteractor
private val authInteractor: AuthInteractor,
private val launchInteractor: LaunchInteractor
) : MvpPresenter<MvpView>() {
fun coldStart() {
if (authInteractor.isSignedIn()) router.newRootScreen(Screens.DRAWER_FLOW)
else router.newRootScreen(Screens.AUTH_FLOW)
if (authInteractor.isSignedIn()) {
router.newRootScreen(Screens.DRAWER_FLOW)
} else {
router.newRootScreen(Screens.AUTH_FLOW)
}
if (launchInteractor.isFirstLaunch) {
router.navigateTo(Screens.PRIVACY_POLICY_FLOW)
}
}
}
\ No newline at end of file
......@@ -40,6 +40,8 @@ class AboutPresenter @Inject constructor(
fun onShowLibrariesClicked() = router.startFlow(Screens.APP_LIBRARIES_FLOW)
fun onPrivacyPolicyClicked() = router.startFlow(Screens.PRIVACY_POLICY_FLOW)
fun onDeveloperClicked(id: Long) = router.startFlow(Screens.USER_FLOW, id)
fun onMenuPressed() = menuController.open()
......
......@@ -42,7 +42,7 @@ class IssueInfoPresenter @Inject constructor(
.getIssue(projectId, issueId)
.flatMap { issue ->
mdConverter
.markdownToSpannable(issue.description ?: "")
.markdownToSpannable(issue.description)
.map { Pair(issue, it) }
},
projectInteractor.getProject(projectId),
......
......@@ -42,7 +42,7 @@ class MergeRequestInfoPresenter @Inject constructor(
.getMergeRequest(projectId, mrId)
.flatMap { mr ->
mdConverter
.markdownToSpannable(mr.description ?: "")
.markdownToSpannable(mr.description)
.map { Pair(mr, it) }
},
projectInteractor.getProject(projectId),
......
package ru.terrakok.gitlabclient.presentation.project.events
import com.arellomobile.mvp.InjectViewState
import ru.terrakok.gitlabclient.Screens
import ru.terrakok.gitlabclient.entity.app.target.TargetHeader
import ru.terrakok.gitlabclient.extension.openInfo
import ru.terrakok.gitlabclient.model.interactor.event.EventInteractor
import ru.terrakok.gitlabclient.model.system.flow.FlowRouter
import ru.terrakok.gitlabclient.presentation.global.BasePresenter
import ru.terrakok.gitlabclient.presentation.global.ErrorHandler
import ru.terrakok.gitlabclient.presentation.global.MarkDownConverter
import ru.terrakok.gitlabclient.presentation.global.Paginator
import ru.terrakok.gitlabclient.toothpick.PrimitiveWrapper
import ru.terrakok.gitlabclient.toothpick.qualifier.ProjectId
import javax.inject.Inject
/**
* @author Konstantin Tskhovrebov (aka terrakok) on 15.06.17.
*/
@InjectViewState
class ProjectEventsPresenter @Inject constructor(
@ProjectId private val projectIdWrapper: PrimitiveWrapper<Long>,
private val eventInteractor: EventInteractor,
private val mdConverter: MarkDownConverter,
private val errorHandler: ErrorHandler,
private val router: FlowRouter
) : BasePresenter<ProjectEventsView>() {
private val projectId = projectIdWrapper.value
override fun onFirstViewAttach() {
super.onFirstViewAttach()
refreshEvents()
}
private val paginator = Paginator(
{
eventInteractor.getProjectEvents(projectId, it)
.flattenAsObservable { it }
.concatMap { item ->
mdConverter.markdownToSpannable(item.body.toString())
.map { md -> item.copy(body = md) }
.toObservable()
}
.toList()
},
object : Paginator.ViewController<TargetHeader> {
override fun showEmptyProgress(show: Boolean) {
viewState.showEmptyProgress(show)
}
override fun showEmptyError(show: Boolean, error: Throwable?) {
if (error != null) {
errorHandler.proceed(error, { viewState.showEmptyError(show, it) })
} else {
viewState.showEmptyError(show, null)
}
}
override fun showErrorMessage(error: Throwable) {
errorHandler.proceed(error, { viewState.showMessage(it) })
}
override fun showEmptyView(show: Boolean) {
viewState.showEmptyView(show)
}
override fun showData(show: Boolean, data: List<TargetHeader>) {
viewState.showEvents(show, data)
}
override fun showRefreshProgress(show: Boolean) {
viewState.showRefreshProgress(show)
}
override fun showPageProgress(show: Boolean) {
viewState.showPageProgress(show)
}
}
)
fun onItemClick(item: TargetHeader) = item.openInfo(router)
fun onUserClick(userId: Long) = router.startFlow(Screens.USER_FLOW, userId)
fun refreshEvents() = paginator.refresh()
fun loadNextEventsPage() = paginator.loadNewPage()
fun onBackPressed() = router.exit()
override fun onDestroy() {
super.onDestroy()
paginator.release()
}
}
\ No newline at end of file
package ru.terrakok.gitlabclient.presentation.project.events
import com.arellomobile.mvp.MvpView
import com.arellomobile.mvp.viewstate.strategy.AddToEndSingleStrategy
import com.arellomobile.mvp.viewstate.strategy.OneExecutionStateStrategy
import com.arellomobile.mvp.viewstate.strategy.StateStrategyType
import ru.terrakok.gitlabclient.entity.app.target.TargetHeader
/**
* @author Konstantin Tskhovrebov (aka terrakok) on 15.06.17.
*/
@StateStrategyType(AddToEndSingleStrategy::class)
interface ProjectEventsView : MvpView {
fun showRefreshProgress(show: Boolean)
fun showEmptyProgress(show: Boolean)
fun showPageProgress(show: Boolean)
fun showEmptyView(show: Boolean)
fun showEmptyError(show: Boolean, message: String?)
fun showEvents(show: Boolean, events: List<TargetHeader>)
@StateStrategyType(OneExecutionStateStrategy::class)
fun showMessage(message: String)
}
\ No newline at end of file
......@@ -42,6 +42,7 @@ class AboutFragment : BaseFragment(), AboutView {
toolbar.setNavigationOnClickListener { presenter.onMenuPressed() }
feedbackView.setOnClickListener { tryOpenLink(supportUrl) }
librariesView.setOnClickListener { presenter.onShowLibrariesClicked() }
privacyPolicyView.setOnClickListener { presenter.onPrivacyPolicyClicked() }
}
override fun showAppInfo(appInfo: AppInfo) {
......
package ru.terrakok.gitlabclient.ui.privacypolicy
import android.os.Bundle
import android.view.View
import kotlinx.android.synthetic.main.fragment_privacy_policy.*
import ru.terrakok.cicerone.Router
import ru.terrakok.gitlabclient.R
import ru.terrakok.gitlabclient.toothpick.DI
import ru.terrakok.gitlabclient.ui.global.BaseFragment
import toothpick.Toothpick
import javax.inject.Inject
/**
* Created by Konstantin Tskhovrebov (aka @terrakok) on 26.09.18.
*/
class PrivacyPolicyFragment : BaseFragment() {
override val layoutRes = R.layout.fragment_privacy_policy
@Inject
lateinit var router: Router
override fun onCreate(savedInstanceState: Bundle?) {
Toothpick.inject(this, Toothpick.openScope(DI.APP_SCOPE))
super.onCreate(savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar.setNavigationOnClickListener { onBackPressed() }
okButton.setOnClickListener { onBackPressed() }
webView.loadData(PRIVACY_POLICY_HTML, null, null)
}
override fun onBackPressed() {
super.onBackPressed()
router.exit()
}
}
private const val PRIVACY_POLICY_HTML: String = """
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width'>
<title>Privacy Policy</title>
<style> body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; padding:1em; } </style>
</head>
<body>
<h2>Privacy Policy</h2> <p> Konstantin Tskhovrebov built the GitFox app as an Open Source app. This SERVICE is provided by Konstantin Tskhovrebov at no cost and is intended for use as is.
</p> <p>This page is used to inform visitors regarding my policies with the collection, use, and
disclosure of Personal Information if anyone decided to use my Service.
</p> <p>If you choose to use my Service, then you agree to the collection and use of information in relation
to this policy. The Personal Information that I collect is used for providing and improving the
Service. I will not use or share your information with anyone except as described
in this Privacy Policy.
</p> <p>The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible
at GitFox unless otherwise defined in this Privacy Policy.
</p> <p><strong>Information Collection and Use</strong></p> <p>For a better experience, while using our Service, I may require you to provide us with certain
personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way.
</p> <p>The app does use third party services that may collect information used to identify you.</p> <div><p>Link to privacy policy of third party service providers used by the app</p> <ul><!----> <!----> <li><a href="https://firebase.google.com/policies/analytics" target="_blank">Firebase Analytics</a></li> <!----> <li><a href="http://try.crashlytics.com/terms/privacy-policy.pdf" target="_blank">Crashlytics</a></li> <!----> <!----></ul></div> <p><strong>Log Data</strong></p> <p> I want to inform you that whenever you use my Service, in a case of an
error in the app I collect data and information (through third party products) on your phone
called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address,
device name, operating system version, the configuration of the app when utilizing my Service,
the time and date of your use of the Service, and other statistics.
</p> <p><strong>Cookies</strong></p> <p>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These
are sent to your browser from the websites that you visit and are stored on your device's internal memory.
</p> <p>This Service does not use these “cookies” explicitly. However, the app may use third party code and libraries
that use “cookies” to collect information and improve their services. You have the option to either
accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to
refuse our cookies, you may not be able to use some portions of this Service.
</p> <p><strong>Service Providers</strong></p> <p> I may employ third-party companies and individuals due to the following reasons:</p> <ul><li>To facilitate our Service;</li> <li>To provide the Service on our behalf;</li> <li>To perform Service-related services; or</li> <li>To assist us in analyzing how our Service is used.</li></ul> <p> I want to inform users of this Service that these third parties have access to your
Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they
are obligated not to disclose or use the information for any other purpose.
</p> <p><strong>Security</strong></p> <p> I value your trust in providing us your Personal Information, thus we are striving
to use commercially acceptable means of protecting it. But remember that no method of transmission over
the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee
its absolute security.
</p> <p><strong>Links to Other Sites</strong></p> <p>This Service may contain links to other sites. If you click on a third-party link, you will be directed
to that site. Note that these external sites are not operated by me. Therefore, I strongly
advise you to review the Privacy Policy of these websites. I have no control over
and assume no responsibility for the content, privacy policies, or practices of any third-party sites
or services.
</p> <p><strong>Children’s Privacy</strong></p> <p>These Services do not address anyone under the age of 13. I do not knowingly collect
personally identifiable information from children under 13. In the case I discover that a child
under 13 has provided me with personal information, I immediately delete this from
our servers. If you are a parent or guardian and you are aware that your child has provided us with personal
information, please contact me so that I will be able to do necessary actions.
</p> <p><strong>Changes to This Privacy Policy</strong></p> <p> I may update our Privacy Policy from time to time. Thus, you are advised to review
this page periodically for any changes. I will notify you of any changes by posting
the new Privacy Policy on this page. These changes are effective immediately after they are posted on
this page.
</p> <p><strong>Contact Us</strong></p> <p>If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact
me.
</p>
</body>
</html>
"""
\ No newline at end of file