A
Android Integration
Native Android app integration with Kotlin/Java
Native AndroidKotlin/JavaModern Architecture
Quick Start
1. Add Dependencies (build.gradle)
gradle
Click to copy
// app/build.gradle
dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// For ViewBinding
viewBinding {
enabled = true
}
}
// Add internet permission in AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />2. API Service Interface (Kotlin)
kotlin
Click to copy
// CalligraphyApiService.kt
import retrofit2.Response
import retrofit2.http.*
interface CalligraphyApiService {
@POST("v2/generate")
suspend fun generateCalligraphy(
@Header("x-api-key") apiKey: String,
@Body request: GenerateRequest
): Response<CalligraphyResponse>
@POST("v2/download-svg")
suspend fun downloadSvg(
@Header("x-api-key") apiKey: String,
@Body request: SvgDownloadRequest
): Response<SvgDownloadResponse>
@POST("v2/download-image")
suspend fun downloadImage(
@Header("x-api-key") apiKey: String,
@Body request: ImageDownloadRequest
): Response<ImageDownloadResponse>
}
// Data classes for API requests and responses
data class GenerateRequest(
val text: String,
val language: String = "hindi",
val fontStyle: String = "calligraphy",
val count: Int = 1,
val imageFormat: String = "png"
)
data class SvgDownloadRequest(
val resultText: String,
val fontName: String
)
data class ImageDownloadRequest(
val resultText: String,
val fontName: String,
val imageWidth: Int = 800,
val imageHeight: Int = 300,
val imageQuality: Int = 90,
val imageFormat: String = "png",
val backgroundColor: String = "#ffffff",
val textColor: String = "#000000"
)
data class CalligraphyResponse(
val success: Boolean,
val data: CalligraphyData?
)
data class CalligraphyData(
val results: List<CalligraphyResult>,
val metadata: CalligraphyMetadata,
val usage: CalligraphyUsage
)
data class CalligraphyResult(
val id: String,
val resultText: String,
val fontFamily: String,
val cdnUrl: String?,
val appliedVariants: List<AppliedVariant>
)
data class AppliedVariant(
val type: String,
val position: Int,
val character: String
)
data class CalligraphyMetadata(
val totalVariations: Int,
val averageVariantsApplied: Double,
val fontFamily: String
)
data class CalligraphyUsage(
val creditsUsed: Int,
val remainingCredits: Int
)
data class SvgDownloadResponse(
val success: Boolean,
val data: SvgData?
)
data class SvgData(
val resultText: String,
val fontFamily: String,
val svgBase64: String,
val svgString: String,
val dimensions: Dimensions
)
data class ImageDownloadResponse(
val success: Boolean,
val data: ImageData?
)
data class ImageData(
val resultText: String,
val fontName: String,
val format: String,
val imageBase64: String,
val dimensions: Dimensions,
val quality: Int,
val backgroundColor: String,
val textColor: String
)
data class Dimensions(
val width: Int,
val height: Int
)3. API Client Setup
kotlin
Click to copy
// ApiClient.kt
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private const val BASE_URL = "https://api.calligraphymaker.com/api/"
const val API_KEY = "YOUR_API_KEY_HERE" // Store securely in production
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val calligraphyService: CalligraphyApiService = retrofit.create(CalligraphyApiService::class.java)
}
// Repository class for API calls
class CalligraphyRepository {
private val apiService = ApiClient.calligraphyService
suspend fun generateCalligraphy(
text: String,
language: String = "hindi",
fontStyle: String = "calligraphy",
count: Int = 1
): Result<CalligraphyData> {
return try {
val request = GenerateRequest(
text = text,
language = language,
fontStyle = fontStyle,
count = count
)
val response = apiService.generateCalligraphy(ApiClient.API_KEY, request)
if (response.isSuccessful && response.body()?.success == true) {
response.body()?.data?.let { data ->
Result.success(data)
} ?: Result.failure(Exception("No data received"))
} else {
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun downloadSvg(
resultText: String,
fontName: String
): Result<SvgData> {
return try {
val request = SvgDownloadRequest(resultText, fontName)
val response = apiService.downloadSvg(ApiClient.API_KEY, request)
if (response.isSuccessful && response.body()?.success == true) {
response.body()?.data?.let { data ->
Result.success(data)
} ?: Result.failure(Exception("No data received"))
} else {
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}4. ViewModel Implementation
kotlin
Click to copy
// CalligraphyViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CalligraphyViewModel(
private val repository: CalligraphyRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CalligraphyUiState())
val uiState: StateFlow<CalligraphyUiState> = _uiState.asStateFlow()
fun generateCalligraphy(
text: String,
language: String = "hindi",
fontStyle: String = "calligraphy",
count: Int = 3
) {
if (text.isBlank()) {
_uiState.value = _uiState.value.copy(
error = "Please enter some text"
)
return
}
_uiState.value = _uiState.value.copy(
isLoading = true,
error = null
)
viewModelScope.launch {
repository.generateCalligraphy(text, language, fontStyle, count)
.onSuccess { data ->
_uiState.value = _uiState.value.copy(
isLoading = false,
results = data.results,
usage = data.usage,
error = null
)
}
.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = exception.message ?: "Unknown error occurred"
)
}
}
}
fun downloadSvg(resultText: String, fontName: String) {
_uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch {
repository.downloadSvg(resultText, fontName)
.onSuccess { svgData ->
_uiState.value = _uiState.value.copy(
isLoading = false,
downloadedSvg = svgData
)
}
.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = exception.message ?: "Download failed"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
fun clearResults() {
_uiState.value = _uiState.value.copy(
results = emptyList(),
downloadedSvg = null
)
}
}
data class CalligraphyUiState(
val isLoading: Boolean = false,
val results: List<CalligraphyResult> = emptyList(),
val usage: CalligraphyUsage? = null,
val downloadedSvg: SvgData? = null,
val error: String? = null
)
class CalligraphyViewModelFactory(
private val repository: CalligraphyRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CalligraphyViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return CalligraphyViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}UI Implementation
MainActivity.kt
kotlin
Click to copy
// MainActivity.kt
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.bumptech.glide.Glide
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: CalligraphyResultsAdapter
private val viewModel: CalligraphyViewModel by viewModels {
CalligraphyViewModelFactory(CalligraphyRepository())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupUI()
observeViewModel()
}
private fun setupUI() {
// Setup RecyclerView
adapter = CalligraphyResultsAdapter(
onItemClick = { result ->
// Handle item click
showResultDetails(result)
},
onDownloadClick = { result ->
// Handle download
downloadImage(result.cdnUrl)
}
)
binding.recyclerViewResults.layoutManager = GridLayoutManager(this, 2)
binding.recyclerViewResults.adapter = adapter
// Setup input
binding.editTextInput.setText("नमस्ते दुनिया")
// Setup language spinner
val languages = arrayOf("Hindi", "Marathi", "Gujarati", "English")
val languageAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, languages)
languageAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerLanguage.adapter = languageAdapter
// Setup font style spinner
val fontStyles = arrayOf("Calligraphy", "Decorative", "Publication")
val styleAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, fontStyles)
styleAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerFontStyle.adapter = styleAdapter
// Setup generate button
binding.buttonGenerate.setOnClickListener {
val text = binding.editTextInput.text.toString()
val language = getSelectedLanguage()
val fontStyle = getSelectedFontStyle()
val count = binding.seekBarCount.progress + 1
viewModel.generateCalligraphy(text, language, fontStyle, count)
}
// Setup count seeker
binding.seekBarCount.max = 5
binding.seekBarCount.progress = 2
binding.textViewCount.text = "Count: 3"
binding.seekBarCount.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.textViewCount.text = "Count: ${progress + 1}"
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
// Setup clear button
binding.buttonClear.setOnClickListener {
viewModel.clearResults()
binding.editTextInput.text?.clear()
}
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { state ->
// Update loading state
binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
binding.buttonGenerate.isEnabled = !state.isLoading
// Update results
adapter.submitList(state.results)
// Show results count
if (state.results.isNotEmpty()) {
binding.textViewResultsCount.text = "Generated ${state.results.size} results"
binding.textViewResultsCount.visibility = View.VISIBLE
} else {
binding.textViewResultsCount.visibility = View.GONE
}
// Update usage info
state.usage?.let { usage ->
binding.textViewCredits.text = "Credits remaining: ${usage.remainingCredits}"
binding.textViewCredits.visibility = View.VISIBLE
}
// Handle errors
state.error?.let { error ->
Toast.makeText(this@MainActivity, error, Toast.LENGTH_LONG).show()
viewModel.clearError()
}
// Handle downloaded SVG
state.downloadedSvg?.let { svgData ->
// Process SVG data
showSvgDownloadDialog(svgData)
}
}
}
}
private fun getSelectedLanguage(): String {
return when (binding.spinnerLanguage.selectedItemPosition) {
0 -> "hindi"
1 -> "marathi"
2 -> "gujarati"
3 -> "english"
else -> "hindi"
}
}
private fun getSelectedFontStyle(): String {
return when (binding.spinnerFontStyle.selectedItemPosition) {
0 -> "calligraphy"
1 -> "decorative"
2 -> "publication"
else -> "calligraphy"
}
}
private fun showResultDetails(result: CalligraphyResult) {
AlertDialog.Builder(this)
.setTitle("Calligraphy Result")
.setMessage("Text: ${result.resultText}\nFont: ${result.fontFamily}\nVariants: ${result.appliedVariants.size}")
.setPositiveButton("Download SVG") { _, _ ->
viewModel.downloadSvg(result.resultText, result.fontFamily)
}
.setNegativeButton("Close", null)
.show()
}
private fun downloadImage(imageUrl: String?) {
imageUrl?.let { url ->
// Implement image download using DownloadManager
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
.setTitle("Calligraphy Image")
.setDescription("Downloading calligraphy image...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "calligraphy_${System.currentTimeMillis()}.png")
downloadManager.enqueue(request)
Toast.makeText(this, "Download started", Toast.LENGTH_SHORT).show()
}
}
private fun showSvgDownloadDialog(svgData: SvgData) {
// Show SVG download success dialog
AlertDialog.Builder(this)
.setTitle("SVG Downloaded")
.setMessage("SVG file generated successfully!\nDimensions: ${svgData.dimensions.width}x${svgData.dimensions.height}")
.setPositiveButton("Save to Device") { _, _ ->
saveSvgToDevice(svgData.svgString)
}
.setNegativeButton("Close", null)
.show()
}
private fun saveSvgToDevice(svgContent: String) {
// Implement SVG file saving
try {
val fileName = "calligraphy_${System.currentTimeMillis()}.svg"
val file = File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
file.writeText(svgContent)
Toast.makeText(this, "SVG saved to Downloads", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Failed to save SVG", Toast.LENGTH_SHORT).show()
}
}
}RecyclerView Adapter
kotlin
Click to copy
// CalligraphyResultsAdapter.kt
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
class CalligraphyResultsAdapter(
private val onItemClick: (CalligraphyResult) -> Unit,
private val onDownloadClick: (CalligraphyResult) -> Unit
) : ListAdapter<CalligraphyResult, CalligraphyResultsAdapter.ViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemCalligraphyResultBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class ViewHolder(
private val binding: ItemCalligraphyResultBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(result: CalligraphyResult) {
// Load image using Glide
if (result.cdnUrl != null) {
Glide.with(binding.root.context)
.load(result.cdnUrl)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error)
.into(binding.imageViewResult)
}
// Set text
binding.textViewResultText.text = result.resultText
binding.textViewFontFamily.text = result.fontFamily
binding.textViewVariants.text = "${result.appliedVariants.size} variants"
// Set click listeners
binding.root.setOnClickListener {
onItemClick(result)
}
binding.buttonDownload.setOnClickListener {
onDownloadClick(result)
}
// Show/hide download button based on image availability
binding.buttonDownload.visibility = if (result.cdnUrl != null) {
View.VISIBLE
} else {
View.GONE
}
}
}
private class DiffCallback : DiffUtil.ItemCallback<CalligraphyResult>() {
override fun areItemsTheSame(oldItem: CalligraphyResult, newItem: CalligraphyResult): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: CalligraphyResult, newItem: CalligraphyResult): Boolean {
return oldItem == newItem
}
}
}Layout Files
activity_main.xml
xml
Click to copy
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Calligraphy Generator"
android:textSize="24sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- Input Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter Text"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/editTextInput"
android:layout_width="match_parent"
android:layout_height="100dp"
android:gravity="top"
android:hint="Enter text to convert to calligraphy"
android:inputType="textMultiLine"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginEnd="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Language"
android:textStyle="bold" />
<Spinner
android:id="@+id/spinnerLanguage"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Font Style"
android:textStyle="bold" />
<Spinner
android:id="@+id/spinnerFontStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/textViewCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Count: 3"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<SeekBar
android:id="@+id/seekBarCount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/buttonGenerate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Generate Calligraphy"
android:layout_marginEnd="8dp" />
<Button
android:id="@+id/buttonClear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Clear"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Loading -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<!-- Results Info -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/textViewResultsCount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/textViewCredits"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<!-- Results Grid -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewResults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>item_calligraphy_result.xml
xml
Click to copy
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/imageViewResult"
android:layout_width="match_parent"
android:layout_height="120dp"
android:scaleType="centerInside"
android:background="@color/light_gray" />
<TextView
android:id="@+id/textViewResultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxLines="2"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="12sp" />
<TextView
android:id="@+id/textViewFontFamily"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="10sp"
android:textColor="@color/gray" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/textViewVariants"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="10sp"
android:textColor="@color/gray" />
<Button
android:id="@+id/buttonDownload"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="Download"
android:textSize="10sp"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>What You'll Build
Native Android App
Full-featured Kotlin/Java app
MVVM Architecture
Clean architecture with ViewModel
Image Loading
Glide integration for image caching
Download Management
Built-in download functionality
Requirements
Android API 21+ (Android 5.0)
Kotlin 1.8+ or Java 8+
Android Studio
API Key from Calligraphy API
Other Integrations