// MyYarns Android SDK โ drop this file into your project
// Full docs: https://myyarns.sendbaba.com/developers
// Requires: implementation("com.squareup.okhttp3:okhttp:4.12.0") in build.gradle.kts
// AndroidManifest.xml:
//
// Usage: MyYarns.init(this, "sb_chat_xxxx") โ in Application.onCreate()
// MyYarns.open(context) โ from any button
package com.sendbaba.myyarns
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.*
import okhttp3.*
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class ChatMessage(val id: String, val text: String, val sender: String, val timestamp: Long = System.currentTimeMillis()) {
val isVisitor: Boolean get() = sender == "visitor"
}
data class WidgetConfig(val name: String = "Support", val color: Int = Color.parseColor("#25d16e"), val greeting: String = "Hi! How can we help?")
object MyYarns {
private const val BASE = "https://myyarns.sendbaba.com"
private const val WS = "wss://myyarns.sendbaba.com"
internal var widgetKey: String = ""
internal var config = WidgetConfig()
internal val messages = mutableListOf()
internal var isConnected = false; internal var isTyping = false; internal var unreadCount = 0
internal var visitorName: String? = null; internal var visitorEmail: String? = null; internal var visitorPhone: String? = null
private var webSocket: WebSocket? = null
private val client = OkHttpClient.Builder().pingInterval(20, TimeUnit.SECONDS).build()
private val mainHandler = Handler(Looper.getMainLooper())
private var typingHandler = Handler(Looper.getMainLooper())
internal val listeners = mutableListOf<() -> Unit>()
internal val sessionId: String by lazy { "android_${System.currentTimeMillis()}_${(Math.random()*100000).toInt()}" }
fun init(context: Context, widgetKey: String) { this.widgetKey = widgetKey; loadConfig(); loadHistory(); connect() }
fun setVisitor(name: String? = null, email: String? = null, phone: String? = null) { visitorName=name; visitorEmail=email; visitorPhone=phone; if(isConnected) sendVisitorInfo() }
fun open(context: Context) { val i=Intent(context, MyYarnsChatActivity::class.java); if(context !is Activity) i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(i); unreadCount=0; notify() }
private fun loadConfig() { Thread { try { val r=Request.Builder().url("$BASE/api/widgets/key/$widgetKey").build(); client.newCall(r).execute().use { resp -> val j=JSONObject(resp.body?.string()?:return@Thread); if(j.optBoolean("success")){ val w=j.getJSONObject("widget"); val hex=w.optString("color","#25d16e").trimStart('#'); config=WidgetConfig(name=w.optString("name","Support"),color=Color.parseColor("#$hex"),greeting=w.optString("greeting","Hi! How can we help?")); notify() } } } catch(e:Exception){e.printStackTrace()} }.start() }
private fun loadHistory() { Thread { try { val r=Request.Builder().url("$BASE/api/conversations/session/$sessionId/messages").build(); client.newCall(r).execute().use { resp -> val j=JSONObject(resp.body?.string()?:return@Thread); if(j.optBoolean("success")){ val arr=j.optJSONArray("messages")?:return@Thread; for(i in 0 until arr.length()){val m=arr.getJSONObject(i);messages.add(ChatMessage(id=m.optString("id",i.toString()),text=m.optString("content",""),sender=m.optString("sender_type","ai")))}; notify() } } } catch(e:Exception){e.printStackTrace()} }.start() }
internal fun connect() { val r=Request.Builder().url("$WS/ws/$widgetKey/$sessionId").build(); webSocket=client.newWebSocket(r, object:WebSocketListener(){ override fun onOpen(ws:WebSocket,response:Response){isConnected=true;sendVisitorInfo();notify()}; override fun onMessage(ws:WebSocket,text:String){try{val msg=JSONObject(text);when(msg.optString("type")){"message"->{messages.add(ChatMessage(id=msg.optString("id",System.currentTimeMillis().toString()),text=msg.optString("content",""),sender=msg.optString("sender","ai")));unreadCount++;typingHandler.removeCallbacksAndMessages(null);isTyping=false;notify()};"typing"->{isTyping=true;notify();typingHandler.removeCallbacksAndMessages(null);typingHandler.postDelayed({isTyping=false;notify()},3000)}}}catch(e:Exception){e.printStackTrace()}}; override fun onClosed(ws:WebSocket,code:Int,reason:String){isConnected=false;notify();mainHandler.postDelayed({connect()},3000)}; override fun onFailure(ws:WebSocket,t:Throwable,response:Response?){isConnected=false;notify();mainHandler.postDelayed({connect()},3000)} }) }
private fun sendVisitorInfo() { val j=JSONObject().apply{put("type","visitor_info");put("page","android-app");put("device","mobile");put("os","Android");visitorName?.let{put("name",it)};visitorEmail?.let{put("email",it)};visitorPhone?.let{put("phone",it)}}; webSocket?.send(j.toString()) }
internal fun sendMessage(text:String) { val t=text.trim(); if(t.isEmpty())return; messages.add(ChatMessage(id=System.currentTimeMillis().toString(),text=t,sender="visitor")); webSocket?.send(JSONObject().apply{put("type","message");put("content",t)}.toString()); notify() }
private fun notify() { mainHandler.post { listeners.forEach{it()} } }
}
class MyYarnsChatActivity : Activity() {
private lateinit var listView: ListView; private lateinit var inputField: EditText; private lateinit var adapter: MessageAdapter; private lateinit var typingRow: View
private val listener: () -> Unit = { runOnUiThread { adapter.notifyDataSetChanged(); typingRow.visibility=if(MyYarns.isTyping)View.VISIBLE else View.GONE; listView.post{listView.setSelection(adapter.count-1)} } }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); MyYarns.unreadCount=0
val root=LinearLayout(this).apply{orientation=LinearLayout.VERTICAL;setBackgroundColor(Color.parseColor("#F9FAFB"))}
val color=MyYarns.config.color; val name=MyYarns.config.name
// Header
val header=LinearLayout(this).apply{orientation=LinearLayout.HORIZONTAL;setBackgroundColor(color);gravity=Gravity.CENTER_VERTICAL;setPadding(48,48,48,36)
addView(TextView(context).apply{text="๐ฌ";textSize=22f;gravity=Gravity.CENTER;layoutParams=LinearLayout.LayoutParams(80,80).also{it.marginEnd=24}})
val nc=LinearLayout(context).apply{orientation=LinearLayout.VERTICAL;layoutParams=LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f)}
nc.addView(TextView(context).apply{text=name;textSize=16f;setTextColor(Color.WHITE);setTypeface(null,android.graphics.Typeface.BOLD)})
nc.addView(TextView(context).apply{text=if(MyYarns.isConnected)"โ Online" else "โ Reconnecting...";textSize=12f;setTextColor(Color.parseColor("#CCFFD6"))})
addView(nc)
addView(Button(context).apply{text="โ";textSize=14f;setTextColor(Color.WHITE);setBackgroundColor(Color.TRANSPARENT);setOnClickListener{finish()}})}
root.addView(header)
adapter=MessageAdapter(this); listView=ListView(this).apply{this.adapter=this@MyYarnsChatActivity.adapter;divider=null;layoutParams=LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,0,1f)}; root.addView(listView)
typingRow=LinearLayout(this).apply{orientation=LinearLayout.HORIZONTAL;gravity=Gravity.CENTER_VERTICAL;setPadding(32,8,32,8);visibility=View.GONE;addView(TextView(this@MyYarnsChatActivity).apply{text="โ โ โ";textSize=14f;setTextColor(Color.parseColor("#9CA3AF"));setPadding(24,16,24,16);background=GradientDrawable().apply{cornerRadius=32f;setColor(Color.WHITE)}})}; root.addView(typingRow)
val row=LinearLayout(this).apply{orientation=LinearLayout.HORIZONTAL;gravity=Gravity.BOTTOM;setBackgroundColor(Color.WHITE);setPadding(24,24,24,24)}
inputField=EditText(this).apply{hint="Type a message...";textSize=14f;setTextColor(Color.parseColor("#1F2937"));setHintTextColor(Color.parseColor("#9CA3AF"));background=GradientDrawable().apply{cornerRadius=44f;setColor(Color.parseColor("#F9FAFB"));setStroke(2,Color.parseColor("#E5E7EB"))};setPadding(32,20,32,20);maxLines=4;layoutParams=LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f).also{it.marginEnd=16}}; row.addView(inputField)
row.addView(TextView(this).apply{text="โ";textSize=20f;setTextColor(Color.WHITE);gravity=Gravity.CENTER;background=GradientDrawable().apply{shape=GradientDrawable.OVAL;setColor(color)};layoutParams=LinearLayout.LayoutParams(96,96);setOnClickListener{MyYarns.sendMessage(inputField.text.toString());inputField.text.clear()}})
root.addView(row)
root.addView(TextView(this).apply{text="Powered by MyYarns ยท SendBaba";textSize=10f;setTextColor(Color.parseColor("#D1D5DB"));gravity=Gravity.CENTER;setPadding(0,0,0,16)})
setContentView(root); MyYarns.listeners.add(listener); listener()
}
override fun onDestroy() { super.onDestroy(); MyYarns.listeners.remove(listener) }
}
class MessageAdapter(private val ctx: Context) : BaseAdapter() {
override fun getCount() = MyYarns.messages.size; override fun getItem(i:Int) = MyYarns.messages[i]; override fun getItemId(i:Int) = i.toLong()
override fun getView(pos:Int,convertView:View?,parent:ViewGroup): View {
val msg=MyYarns.messages[pos]; val color=MyYarns.config.color
val row=LinearLayout(ctx).apply{orientation=LinearLayout.HORIZONTAL;gravity=if(msg.isVisitor)Gravity.END else Gravity.START;setPadding(24,8,24,8)}
if(!msg.isVisitor) row.addView(TextView(ctx).apply{text="AI";textSize=9f;setTextColor(Color.WHITE);gravity=Gravity.CENTER;background=GradientDrawable().apply{shape=GradientDrawable.OVAL;setColor(color)};layoutParams=LinearLayout.LayoutParams(56,56).also{it.marginEnd=16;it.gravity=Gravity.BOTTOM}})
row.addView(LinearLayout(ctx).apply{orientation=LinearLayout.VERTICAL;background=GradientDrawable().apply{cornerRadii=if(msg.isVisitor)floatArrayOf(32f,32f,32f,32f,32f,32f,8f,8f) else floatArrayOf(32f,32f,32f,32f,8f,8f,32f,32f);setColor(if(msg.isVisitor)color else Color.WHITE)};setPadding(28,20,28,20);layoutParams=LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT).also{it.maximumWidth=(ctx.resources.displayMetrics.widthPixels*0.72).toInt()}
addView(TextView(ctx).apply{text=msg.text;textSize=14f;setTextColor(if(msg.isVisitor)Color.WHITE else Color.parseColor("#1F2937"))})
addView(TextView(ctx).apply{val h=java.util.Calendar.getInstance().apply{timeInMillis=msg.timestamp};text="%02d:%02d".format(h.get(java.util.Calendar.HOUR_OF_DAY),h.get(java.util.Calendar.MINUTE));textSize=10f;gravity=Gravity.END;setTextColor(if(msg.isVisitor)Color.parseColor("#FFFFFFAA") else Color.parseColor("#9CA3AF"))})
})
return row
}
}
class MyYarnsLauncherView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) {
private val badge: TextView; private val icon: TextView
init {
val size=(context.resources.displayMetrics.density*58).toInt(); layoutParams=LayoutParams(size,size)
icon=TextView(context).apply{text="๐ฌ";textSize=24f;gravity=Gravity.CENTER;layoutParams=LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT)}; addView(icon)
badge=TextView(context).apply{textSize=9f;setTextColor(Color.WHITE);gravity=Gravity.CENTER;visibility=GONE;background=GradientDrawable().apply{shape=GradientDrawable.OVAL;setColor(Color.parseColor("#EF4444"))};val bs=(context.resources.displayMetrics.density*20).toInt();layoutParams=LayoutParams(bs,bs,Gravity.TOP or Gravity.END)}; addView(badge)
setOnClickListener{MyYarns.open(context)}; MyYarns.listeners.add{post{refresh()}}; refresh()
}
private fun refresh() { background=GradientDrawable().apply{shape=GradientDrawable.OVAL;setColor(MyYarns.config.color)};elevation=16f;val c=MyYarns.unreadCount;badge.visibility=if(c>0)VISIBLE else GONE;badge.text=if(c>9)"9+" else c.toString() }
}