// 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() } }