This commit is contained in:
2026-09-13 12:15:36 -07:00
commit e473d00f4f
104 changed files with 16080 additions and 0 deletions
@@ -0,0 +1,168 @@
package com.portaltv.capability;
import android.Manifest;
import android.app.*;
import android.os.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.graphics.ImageFormat;
import android.graphics.BitmapFactory;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.*;
import android.util.Size;
import android.view.*;
import android.widget.*;
import java.util.*;
import java.nio.ByteBuffer;
import java.util.concurrent.*;
public class MainActivity extends Activity {
TextView authStatus;
LinearLayout authPanel;
@Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; refreshAuthUi(); Intent i=new Intent(this,PortalStreamingService.class); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); }
@Override protected void onPause(){ PortalStreamingService.activityVisible=false; super.onPause(); }
@Override protected void onStop(){ Intent i=new Intent(this,PortalStreamingService.class); i.setAction("com.portaltv.capability.CLOSE_CLIENTS"); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); super.onStop(); }
TextView log; CameraManager cm; HandlerThread ht; Handler h; CameraDevice cam; ImageReader reader; ImageView preview; IBinder control; IBinder session; IBinder controlToken; IBinder meta; IBinder metaConn; IBinder metaToken; byte[] frameBuf; final java.util.concurrent.atomic.AtomicBoolean frameBusy=new java.util.concurrent.atomic.AtomicBoolean(); int frameCount; float fx=0.5f, fy=0.5f, fs=1.0f;
int screenW,screenH,ctlW=120,logHeaderH=96; boolean controlsExpanded,logExpanded; LinearLayout controlsPanel,controlsContent,logPanel,subFixed,subDesk; TextView subNone; ScrollView logScroll; Button controlsToggle,logToggle; final java.util.Map<String,Button> modeButtons=new java.util.HashMap<>(); String currentMode;
final PortalSmartCamera.StateListener cameraStateListener=state->{
final String mode="ModeSetting_"+state.getMode();
final org.json.JSONObject cfg=state.getConfig();
runOnUiThread(()->{
setCurrentMode(mode);
if("Fixed".equals(state.getMode())&&cfg!=null){
try{
if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX");
if(cfg.has("centerY")) fy=(float)cfg.getDouble("centerY");
if(cfg.has("scale")) fs=(float)cfg.getDouble("scale");
}catch(Exception e){p("state config parse: "+e);}
}
p("Camera state -> "+state.getMode()+" "+cfg);
});
};
final Binder modeListener=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); final String m=data.readInt()!=0?data.readString():null; p("Mode changed (legacy) -> "+(m!=null?m:"(null)")); }catch(Exception e){p("Mode listener parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
final Binder metaReceiver=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); if(data.readInt()!=0){ Bundle b=data.readBundle(getClass().getClassLoader()); String s=""; for(String k:b.keySet()) s+=k+"="+b.get(k)+" "; p("meta: "+s); } }catch(Exception e){p("Meta receiver parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } };
public void onCreate(Bundle b) { super.onCreate(b); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); buildUi();
PortalSmartCamera.start(this);
PortalSmartCamera.addStateListener(cameraStateListener);
if (Build.VERSION.SDK_INT >= 23 && (checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED || checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)) requestPermissions(new String[]{Manifest.permission.CAMERA,Manifest.permission.RECORD_AUDIO},7);
else startAll();
cm=(CameraManager)getSystemService(CAMERA_SERVICE); ht=new HandlerThread("camera"); ht.start(); h=new Handler(ht.getLooper()); p("Running as uid="+android.os.Process.myUid()); inspect();
Intent service = new Intent(this, PortalStreamingService.class);
if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service);
}
volatile boolean pipelineStarted;
synchronized void ensurePipeline(){ if(pipelineStarted)return; pipelineStarted=true; new Thread(()->{ testCamera("0"); startMicLoop(); startAudioEncoder(); startTsMuxer(); }).start(); p("Media pipeline started on first client"); }
void startAll(){ p("Camera UI driven by PortalSmartCamera state events"); }
void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<VChunk>> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} }
void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue<byte[]> q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} }
byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; }
String deviceIp(){ try{ for(java.net.NetworkInterface ni:java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) for(java.net.InetAddress a:java.util.Collections.list(ni.getInetAddresses())) if(!a.isLoopbackAddress()&&a instanceof java.net.Inet4Address) return a.getHostAddress(); }catch(Exception e){} return "?"; }
// ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ----
static int crcMpeg(byte[] d,int off,int len){ int c=0xFFFFFFFF; for(int i=off;i<off+len;i++){ c^=(d[i]&0xFF)<<24; for(int b=0;b<8;b++) c=(c&0x80000000)!=0?(c<<1)^0x04C11DB7:c<<1; } return c; }
byte[] patPacket(){ byte[] s={0x00,(byte)0xB0,0x0D,0x00,0x01,(byte)0xC1,0x00,0x00,0x00,0x01,(byte)0xF0,0x00}; return tablePacket(0,s); }
byte[] pmtPacket(){ byte[] s={0x02,(byte)0xB0,0x17,0x00,0x01,(byte)0xC1,0x00,0x00,(byte)0xE1,0x01,(byte)0xF0,0x00,0x1B,(byte)0xE1,0x01,(byte)0xF0,0x00,0x0F,(byte)0xE1,0x02,(byte)0xF0,0x00}; return tablePacket(0x1000,s); }
byte[] tablePacket(int pid,byte[] sec){ int crc=crcMpeg(sec,0,sec.length); byte[] full=new byte[sec.length+5]; full[0]=0; System.arraycopy(sec,0,full,1,sec.length); int n=sec.length+1; full[n++]=(byte)(crc>>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List<byte[]> pk=packetize(pid,false,full,0); return pk.get(0); }
void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; writePes(0x102,0xE1,a,false); } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; if(vBase<0) vBase=v.pts; if(v.k){ tsBroadcast(patPacket()); tsBroadcast(pmtPacket()); if(vCsd!=null&&!hasSps(v.d)){ byte[] m=new byte[vCsd.length+v.d.length]; System.arraycopy(vCsd,0,m,0,vCsd.length); System.arraycopy(v.d,0,m,vCsd.length,v.d.length); v=new VChunk(m,true,v.pts); } } writePes(0x101,0xE0,v,true); }catch(Exception e){ p("ts mux: "+e); } } }).start(); }
static boolean hasSps(byte[] d){ for(int i=0;i+4<Math.min(d.length,64);i++){ if(d[i]==0&&d[i+1]==0&&d[i+2]==1&&(d[i+3]&0x1F)==7) return true; if(d[i]==0&&d[i+1]==0&&d[i+2]==0&&d[i+3]==1&&(d[i+4]&0x1F)==7) return true; } return false; }
void tsBroadcast(byte[] d){ synchronized(tsClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<byte[]>> it=tsClients.iterator(); while(it.hasNext()){ if(!it.next().offer(d)) it.remove(); } } }
void writePes(int pid,int sid,VChunk c,boolean isVideo){ if(tsClients.isEmpty()) return; long base=isVideo?vBase:aBase; if(base<0){ if(isVideo) vBase=c.pts; else aBase=c.pts; base=c.pts; } long pts=(c.pts-base)*9/100; java.io.ByteArrayOutputStream pes=new java.io.ByteArrayOutputStream(); pes.write(0); pes.write(0); pes.write(1); pes.write(sid); int pl=isVideo?0:c.d.length+8; pes.write(pl>>8); pes.write(pl); pes.write(0x80); pes.write(0x80); pes.write(5); pes.write((2<<4)|((int)((pts>>30)&7)<<1)|1); pes.write((int)(pts>>22)&0xFF); pes.write((int)(((pts>>15)&0x7F)<<1)|1); pes.write((int)(pts>>7)&0xFF); pes.write((int)((pts&0x7F)<<1)|1); pes.write(c.d,0,c.d.length); for(byte[] p:packetize(pid,isVideo,pes.toByteArray(),pts)) tsBroadcast(p); }
java.util.List<byte[]> packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List<byte[]> out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off<pes.length){ byte[] p=new byte[188]; p[0]=0x47; p[1]=(byte)((first?0x40:0)|((pid>>8)&0x1F)); p[2]=(byte)pid; boolean pcr=first&&isVideo; int room=184-(pcr?8:0); int remain=pes.length-off; int take=Math.min(remain,room); boolean stuff=take<room; int afc=(pcr||stuff)?3:1; int cc=pid==0x101?ccV++&15:pid==0x102?ccA++&15:pid==0?ccPAT++&15:ccPMT++&15; p[3]=(byte)((afc<<4)|cc); int pos=4; if(afc==3){ int afLen=183-take; p[pos++]= (byte)afLen; p[pos++]=(byte)(pcr?0x10:0); if(pcr){ long b=pcr90k; p[pos++]=(byte)(b>>25); p[pos++]=(byte)(b>>17); p[pos++]=(byte)(b>>9); p[pos++]=(byte)(b>>1); p[pos++]=(byte)((b<<7)|0x7E); p[pos++]=0; } while(pos<4+1+afLen) p[pos++]=(byte)0xFF; } System.arraycopy(pes,off,p,pos,take); off+=take; first=false; out.add(p); } return out; }
// ---- end TS muxer ----
void startWebcamServer(){ if(httpSock!=null)return; new Thread(()->{ try{ httpSock=new java.net.ServerSocket(5654); p("Webcam server: http://"+deviceIp()+":5654/ (video=/video.h264 audio=/audio.aac)"); while(true){ final java.net.Socket s=httpSock.accept(); new Thread(()->handleHttp(s)).start(); } }catch(Exception e){p("Webcam server failed: "+e);} }).start(); }
void handleHttp(java.net.Socket s){ try{ s.setTcpNoDelay(true); java.io.BufferedReader in=new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); String line=in.readLine(); if(line==null){s.close();return;} String path=line.split(" ")[1]; while((line=in.readLine())!=null&&!line.isEmpty()){} java.io.OutputStream out=s.getOutputStream();
if(path.equals("/")||path.startsWith("/index")){ String h="<html><body style='background:#111;color:#eee;font-family:monospace'><h3>Portal webcam</h3><b><a style='color:#8af' href='/stream.ts'>/stream.ts</a> (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)</b><br>video: <a style='color:#8af' href='/video.h264'>/video.h264</a> (raw H.264 Annex B)<br>audio: <a style='color:#8af' href='/audio.aac'>/audio.aac</a> (raw AAC ADTS)<br><br>ffplay http://"+deviceIp()+":5654/stream.ts<br>mpv http://"+deviceIp()+":5654/stream.ts<br>vlc http://"+deviceIp()+":5654/stream.ts<br></body></html>"; byte[] b=h.getBytes(); out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes()); out.write(b); out.flush(); s.close(); return; }
if(path.startsWith("/video.h264")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/h264\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); byte[] csd=vCsd; if(csd!=null){ out.write(csd); out.flush(); } if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<VChunk> q=new java.util.concurrent.ArrayBlockingQueue<>(3); vClients.add(q); boolean started=false; try{ while(true){ VChunk c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!vClients.contains(q)) break; continue; } if(!started){ if(!c.k) continue; started=true; } out.write(c.d); out.flush(); } }finally{ vClients.remove(q); } return; }
if(path.startsWith("/stream.ts")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(24); synchronized(tsClients){ if(tsClients.isEmpty()){ vBase=-1; aBase=-1; } tsClients.add(q); } try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!tsClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ tsClients.remove(q); } return; }
if(path.startsWith("/audio.aac")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: audio/aac\r\nCache-Control: no-store\r\n\r\n".getBytes()); out.flush(); java.util.concurrent.BlockingQueue<byte[]> q=new java.util.concurrent.ArrayBlockingQueue<>(256); aClients.add(q); try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!aClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ aClients.remove(q); } return; }
if(path.startsWith("/control/mode")){ String mode=query(path,"mode"); if(mode==null||!mode.matches("DefaultAuto|Desk|Meeting|Fixed")){ reply(out,400,"mode must be DefaultAuto, Desk, Meeting, or Fixed"); return; } setSmartMode(mode); reply(out,200,"mode requested: "+mode); return; }
if(path.startsWith("/control/fixed")){ try{ fx=Float.parseFloat(query(path,"x")); fy=Float.parseFloat(query(path,"y")); fs=Float.parseFloat(query(path,"scale")); setSmartMode("Fixed"); reply(out,200,String.format("fixed requested: x=%.3f y=%.3f scale=%.3f",fx,fy,fs)); }catch(Exception e){ reply(out,400,"x, y, and scale are required numbers"); } return; }
out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes()); out.flush(); s.close();
}catch(Exception e){} try{ s.close(); }catch(Exception e){} }
String query(String path,String key){ int q=path.indexOf('?'); if(q<0)return null; for(String p:path.substring(q+1).split("&")){String[] kv=p.split("=",2);if(kv.length==2&&java.net.URLDecoder.decode(kv[0]).equals(key))return java.net.URLDecoder.decode(kv[1]);}return null; }
void reply(java.io.OutputStream out,int code,String body)throws java.io.IOException{byte[] b=body.getBytes();out.write(("HTTP/1.1 "+code+" OK\r\nContent-Type: text/plain\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes());out.write(b);out.flush();}
public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); }
void buildUi(){
screenW=getResources().getDisplayMetrics().widthPixels; screenH=getResources().getDisplayMetrics().heightPixels;
LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(0xff101010);
authStatus=new TextView(this); authStatus.setTextColor(0xffffffff); authStatus.setPadding(12,8,12,8); root.addView(authStatus,new LinearLayout.LayoutParams(-1,70));
authPanel=new LinearLayout(this); authPanel.setOrientation(LinearLayout.VERTICAL); root.addView(authPanel,new LinearLayout.LayoutParams(-1,110));
Button revokeAll=new Button(this); revokeAll.setText("Revoke all paired clients"); revokeAll.setOnClickListener(v->{ Intent x=new Intent(this,PortalStreamingService.class); x.setAction("com.portaltv.capability.REVOKE_ALL"); if(Build.VERSION.SDK_INT>=26) startForegroundService(x); else startService(x); }); root.addView(revokeAll,new LinearLayout.LayoutParams(-1,70));
LinearLayout main=new LinearLayout(this); main.setOrientation(LinearLayout.HORIZONTAL); root.addView(main,new LinearLayout.LayoutParams(-1,0,1));
preview=new ImageView(this); preview.setBackgroundColor(0xff202020); preview.setScaleType(ImageView.ScaleType.FIT_CENTER); main.addView(preview,new LinearLayout.LayoutParams(0,-1,1));
controlsPanel=new LinearLayout(this); controlsPanel.setOrientation(LinearLayout.VERTICAL); controlsPanel.setBackgroundColor(0xff303030); controlsPanel.setPadding(8,8,8,8); main.addView(controlsPanel,new LinearLayout.LayoutParams(ctlW,-1));
LinearLayout trow=new LinearLayout(this); controlsToggle=new Button(this); controlsToggle.setText("<"); focusFx(controlsToggle); trow.addView(controlsToggle,new LinearLayout.LayoutParams(-2,84)); controlsPanel.addView(trow); controlsToggle.setOnClickListener(v->toggleControls());
ScrollView cs=new ScrollView(this); controlsContent=new LinearLayout(this); controlsContent.setOrientation(LinearLayout.VERTICAL); controlsContent.setVisibility(View.GONE); cs.addView(controlsContent); controlsPanel.addView(cs,new LinearLayout.LayoutParams(-1,0,1));
TextView ml=new TextView(this); ml.setText("Mode"); ml.setTextColor(0xffffffff); controlsContent.addView(ml);
for(final String mode:new String[]{"DefaultAuto","Desk","Meeting","Fixed"}){ Button x=new Button(this); x.setText(mode); x.setTextColor(0xffffffff); x.setBackgroundColor(C_IDLE); x.setOnClickListener(v->setSmartMode(mode)); x.setOnFocusChangeListener((v,f)->styleModeButton((Button)v,mode)); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; controlsContent.addView(x,lp); modeButtons.put(mode,x); }
FrameLayout subHost=new FrameLayout(this); LinearLayout.LayoutParams slp=new LinearLayout.LayoutParams(-1,-2); slp.topMargin=12; controlsContent.addView(subHost,slp);
subFixed=new LinearLayout(this); subFixed.setOrientation(LinearLayout.VERTICAL); subFixed.setVisibility(View.GONE);
LinearLayout fr1=new LinearLayout(this); LinearLayout fr2=new LinearLayout(this);
String[][] pan={{"<","-0.05","0","1"},{">","0.05","0","1"},{"^","0","-0.05","1"},{"v","0","0.05","1"}}; for(final String[] t:pan){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr1.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
String[][] zm={{"Z+","0","0","0.85"},{"Z-","0","0","1.1765"}}; for(final String[] t:zm){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr2.addView(x,new LinearLayout.LayoutParams(0,84,1)); }
subFixed.addView(fr1); subFixed.addView(fr2); subHost.addView(subFixed);
subDesk=new LinearLayout(this); subDesk.setOrientation(LinearLayout.VERTICAL); subDesk.setVisibility(View.GONE);
for(final float t:new float[]{0.0f,0.5f,1.0f}){ Button x=new Button(this); x.setText("tight "+t); x.setOnClickListener(v->{Bundle b=new Bundle(); b.putFloat("additional_stable_framing_tightness",t); sendMode("ModeSetting_Desk",b,"Desk tight="+t);}); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; subDesk.addView(x,lp); }
subHost.addView(subDesk);
subNone=new TextView(this); subNone.setText("No mode parameters"); subNone.setTextColor(0xffaaaaaa); subNone.setVisibility(View.GONE); subHost.addView(subNone);
logPanel=new LinearLayout(this); logPanel.setOrientation(LinearLayout.VERTICAL); logPanel.setBackgroundColor(0xff282828); root.addView(logPanel,new LinearLayout.LayoutParams(-1,logHeaderH));
LinearLayout hdr=new LinearLayout(this); hdr.setGravity(16); TextView lt=new TextView(this); lt.setText("Log"); lt.setTextColor(0xffffffff); hdr.addView(lt,new LinearLayout.LayoutParams(0,-2,1)); logToggle=new Button(this); logToggle.setText("^"); focusFx(logToggle); hdr.addView(logToggle,new LinearLayout.LayoutParams(-2,-2)); logToggle.setOnClickListener(v->toggleLog()); logPanel.addView(hdr);
log=new TextView(this); log.setTextSize(14); log.setTextColor(0xffeeeeee); logScroll=new ScrollView(this); logScroll.addView(log); logScroll.setVisibility(View.GONE); logPanel.addView(logScroll,new LinearLayout.LayoutParams(-1,0,1));
setContentView(root);
}
void refreshAuthUi(){ if(authPanel==null)return; android.content.SharedPreferences p=getSharedPreferences("auth",MODE_PRIVATE); int av=p.getInt("activeVideo",0), aa=p.getInt("activeAudio",0); authStatus.setText((p.getString("pairingPin","").isEmpty()?"HTTPS ready":"Pairing PIN: "+p.getString("pairingPin",""))+" Active video: "+av+" audio: "+aa); authPanel.removeAllViews(); java.util.Set<String> ts=p.getStringSet("tokens",java.util.Collections.emptySet()); for(String h:ts){ String m=p.getString("client."+h,"unknown"); Button b=new Button(this); b.setText("Revoke "+m.replace('|',' ')); b.setOnClickListener(v->{ java.util.Set<String> n=p.getStringSet("tokens",java.util.Collections.emptySet()); n=new java.util.HashSet<>(n); n.remove(h); p.edit().putStringSet("tokens",n).remove("client."+h).apply(); refreshAuthUi(); }); authPanel.addView(b,new LinearLayout.LayoutParams(-1,60)); } }
Button mkBtn(String t,View.OnClickListener l){ Button x=new Button(this); x.setText(t); x.setOnClickListener(l); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; x.setLayoutParams(lp); return x; }
void toggleControls(){ controlsExpanded=!controlsExpanded; android.view.ViewGroup.LayoutParams lp=controlsPanel.getLayoutParams(); lp.width=controlsExpanded?screenW/5:ctlW; controlsPanel.setLayoutParams(lp); controlsContent.setVisibility(controlsExpanded?View.VISIBLE:View.GONE); controlsToggle.setText(controlsExpanded?">":"<"); }
void toggleLog(){ logExpanded=!logExpanded; android.view.ViewGroup.LayoutParams lp=logPanel.getLayoutParams(); lp.height=logExpanded?screenH/3:logHeaderH; logPanel.setLayoutParams(lp); logScroll.setVisibility(logExpanded?View.VISIBLE:View.GONE); logToggle.setText(logExpanded?"v":"^"); }
static final int C_ACTIVE=0xff2e7d32, C_IDLE=0xff424242, C_FOCUS=0xffff9800;
void styleModeButton(Button b,String mode){ boolean a=("ModeSetting_"+mode).equals(currentMode); if(b.isFocused()){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackgroundColor(a?C_ACTIVE:C_IDLE); b.setTextColor(0xffffffff); } }
void focusFx(Button b){ final android.graphics.drawable.Drawable d=b.getBackground(); final android.content.res.ColorStateList tc=b.getTextColors(); b.setOnFocusChangeListener((v,f)->{ if(f){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackground(d); b.setTextColor(tc); } }); }
void setCurrentMode(String m){ currentMode=m; runOnUiThread(()->{ for(java.util.Map.Entry<String,Button> e:modeButtons.entrySet()) styleModeButton(e.getValue(),e.getKey()); subFixed.setVisibility("ModeSetting_Fixed".equals(currentMode)?View.VISIBLE:View.GONE); subDesk.setVisibility("ModeSetting_Desk".equals(currentMode)?View.VISIBLE:View.GONE); subNone.setVisibility(("ModeSetting_DefaultAuto".equals(currentMode)||"ModeSetting_Meeting".equals(currentMode))?View.VISIBLE:View.GONE); }); }
void p(String x){android.util.Log.d("PortalCap",x); runOnUiThread(()->log.append(String.format("%tT ",System.currentTimeMillis())+x+"\n"));}
void inspect(){ try{ for(String id:cm.getCameraIdList()){CameraCharacteristics c=cm.getCameraCharacteristics(id); StreamConfigurationMap map=c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); p("Camera "+id+": facing="+c.get(CameraCharacteristics.LENS_FACING)+", sensor="+c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)); if(map!=null){Size[] y=map.getOutputSizes(ImageFormat.YUV_420_888); if(y!=null){String z=""; for(Size q:y) if(q.getWidth()>=1280) z+=q+" "; p(" YUV outputs: "+z);} Size[] j=map.getOutputSizes(ImageFormat.JPEG); if(j!=null){String z=""; for(Size q:j) if(q.getWidth()>=1280) z+=q+" "; p(" JPEG outputs: "+z);}} }}catch(Exception e){p("Inspect error: "+e);}}
void testCamera(final String id){
if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;}
if(cam!=null){cam.close();cam=null;} if(reader!=null){reader.close();reader=null;}
p("Opening camera "+id+" at 3840x2160 YUV...");
try {
reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2);
startVideoEncoder();
frameBusy.set(false); frameCount=0;
reader.setOnImageAvailableListener(r->{Image im=r.acquireLatestImage(); if(im==null)return; if(frameBusy.getAndSet(true)){im.close();return;} ByteBuffer bb=im.getPlanes()[0].getBuffer(); int len=bb.remaining(); if(frameBuf==null||frameBuf.length<len)frameBuf=new byte[len]; bb.get(frameBuf,0,len); im.close(); android.graphics.Bitmap bmp=BitmapFactory.decodeByteArray(frameBuf,0,len); runOnUiThread(()->{android.graphics.drawable.Drawable old=preview.getDrawable(); preview.setImageBitmap(bmp); if(old instanceof android.graphics.drawable.BitmapDrawable)((android.graphics.drawable.BitmapDrawable)old).getBitmap().recycle(); frameBusy.set(false);});},h);
cm.openCamera(id,new CameraDevice.StateCallback(){
public void onOpened(CameraDevice c){
cam=c;
try {
CaptureRequest.Builder q=c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); Surface out=reader.getSurface(); q.addTarget(out); if(vencSurface!=null) q.addTarget(vencSurface);
c.createCaptureSession(vencSurface!=null?Arrays.asList(out,vencSurface):Collections.singletonList(out),new CameraCaptureSession.StateCallback(){
public void onConfigured(CameraCaptureSession s){try{s.setRepeatingRequest(q.build(),null,h);p("Camera "+id+" capture started");}catch(Exception e){p("Capture failed: "+e);}}
public void onConfigureFailed(CameraCaptureSession s){p("Camera "+id+" configuration rejected: "+s);}
},h);
} catch(Exception e){p("Camera "+id+" setup failed: "+e);}
}
public void onDisconnected(CameraDevice c){p("Camera "+id+" disconnected");c.close();}
public void onError(CameraDevice c,int e){p("Camera "+id+" error "+e);c.close();}
},h);
} catch(Exception e){p("Camera "+id+" open failed: "+e);}
}
volatile boolean micLoop; Thread micThread;
static class VChunk{ final byte[] d; final boolean k; final long pts; VChunk(byte[] d,boolean k,long pts){this.d=d;this.k=k;this.pts=pts;} }
final java.util.concurrent.BlockingQueue<VChunk> tsV=new java.util.concurrent.ArrayBlockingQueue<>(120);
final java.util.concurrent.BlockingQueue<VChunk> tsA=new java.util.concurrent.ArrayBlockingQueue<>(256);
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> tsClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
int ccV,ccA,ccPAT,ccPMT; long vBase=-1,aBase=-1;
final java.util.Set<java.util.concurrent.BlockingQueue<VChunk>> vClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<VChunk>>());
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
final java.util.concurrent.BlockingQueue<byte[]> pcmIn=new java.util.concurrent.ArrayBlockingQueue<>(128);
volatile byte[] vCsd; MediaCodec venc,aenc; Surface vencSurface; java.net.ServerSocket httpSock;
void startMicLoop(){ if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){p("Microphone permission not granted");return;} if(micThread!=null)return; micLoop=true; micThread=new Thread(()->{ int n=AudioRecord.getMinBufferSize(48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT); AudioRecord ar=null; try{ ar=new AudioRecord(MediaRecorder.AudioSource.DEFAULT,48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,n*2); ar.startRecording(); p("Mic capture started: actualRate="+ar.getSampleRate()+" minBuf="+n+" state="+ar.getState()); byte[] b=new byte[n]; long tWin=System.nanoTime(),bWin=0; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) p("pcmIn FULL, dropped "+got+"B"); bWin+=got; long now=System.nanoTime(); if(now-tWin>5e9){ p("mic rate: "+(bWin*1e9/(now-tWin))+" B/s (expect 96000), queue="+pcmIn.size()); tWin=now; bWin=0; } } } }catch(Exception e){p("Mic capture failed: "+e);} finally{ try{if(ar!=null){ar.stop();ar.release();}}catch(Exception e){} micThread=null; } }); micThread.start(); }
void sendMode(final String modeName,final Bundle b,final String label){ try{ if(modeName.equals("ModeSetting_Desk")&&b!=null&&b.containsKey("additional_stable_framing_tightness")){ PortalSmartCamera.setDeskTightness(b.getFloat("additional_stable_framing_tightness")); p(label+" sent"); return; } String shortName=modeName.startsWith("ModeSetting_")?modeName.substring("ModeSetting_".length()):modeName; if(shortName.equals("Fixed")) PortalSmartCamera.setMode(shortName,fx,fy,fs); else PortalSmartCamera.setMode(shortName); p(label+" sent"); }catch(Exception e){p(label+" failed: "+e);} }
void setSmartMode(String mode){ if(mode.equals("Fixed")) PortalSmartCamera.setMode(mode,fx,fy,fs); else PortalSmartCamera.setMode(mode); }
void nudgeFixed(float dx,float dy,float sm){ fs=Math.min(1,Math.max(0.1f,fs*sm)); fx=Math.min(1-fs/2,Math.max(fs/2,fx+dx)); fy=Math.min(1-fs/2,Math.max(fs/2,fy+dy)); setSmartMode("Fixed"); }
void ensureSession(final Runnable next){ if(control==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){control=b;p("Smart Camera service bound");ensureSession(next);}public void onServiceDisconnected(ComponentName n){control=null;session=null;}},BIND_AUTO_CREATE);p("Binding Smart Camera service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlService");controlToken=new Binder();q.writeStrongBinder(controlToken); if(!control.transact(2,q,r,0)){p("Smart Camera connect rejected");return;} r.readException(); IBinder connection=r.readStrongBinder();if(connection==null){p("No control connection returned");return;} Parcel cr=Parcel.obtain(),co=Parcel.obtain();cr.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection");cr.writeStrongBinder(new Binder());if(!connection.transact(2,cr,co,0)){p("requestControls rejected");return;}co.readException();session=co.readStrongBinder();if(session==null){p("No control session returned");return;} p("Control session established"); next.run(); }catch(Exception e){p("Control connect failed: "+e);} }
void ensureMeta(final Runnable next){ if(meta==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){meta=b;p("Metadata service bound");ensureMeta(next);}public void onServiceDisconnected(ComponentName n){meta=null;metaConn=null;}},BIND_AUTO_CREATE);p("Binding metadata service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService");metaToken=new Binder();q.writeStrongBinder(metaToken); if(!meta.transact(2,q,r,0)){p("Metadata connect rejected");return;} r.readException(); metaConn=r.readStrongBinder();if(metaConn==null){p("No metadata connection returned");return;} p("Metadata connection established"); next.run(); }catch(Exception e){p("Metadata connect failed: "+e);} }
void queryMode(){ if(metaConn==null){ensureMeta(()->queryMode());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"); if(!metaConn.transact(2,q,r,0)){p("getMode call failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Current mode: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("getMode failed: "+e);} }
void watchModes(){ if(metaConn==null){ensureMeta(()->watchModes());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(modeListener); if(!metaConn.transact(3,q,r,0)){p("subscribeModeChanges failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Watching modes; current: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("subscribeModeChanges failed: "+e);} }
void watchCrop(){ if(metaConn==null){ensureMeta(()->watchCrop());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(metaReceiver); ArrayList<String> t=new ArrayList<String>(); t.add("crop"); q.writeStringList(t); q.writeFloat(1.0f); if(!metaConn.transact(6,q,r,0)){p("subscribeFrameMetadata failed");return;} r.readException(); if(r.readInt()!=0){ Bundle b=r.readBundle(getClass().getClassLoader()); p("crop now: "+b.get("crop")); } p("Watching crop @1Hz"); }catch(Exception e){p("subscribeFrameMetadata failed: "+e);} }
protected void onDestroy(){ PortalSmartCamera.removeStateListener(cameraStateListener); micLoop=false;try{if(venc!=null){venc.stop();venc.release();}}catch(Exception e){} try{if(aenc!=null){aenc.stop();aenc.release();}}catch(Exception e){} try{if(httpSock!=null)httpSock.close();}catch(Exception e){} if(cam!=null)cam.close();if(reader!=null)reader.close();if(ht!=null)ht.quitSafely();super.onDestroy();}
}
@@ -0,0 +1,15 @@
package com.portaltv.capability
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
class PortalBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val service = Intent(context, PortalStreamingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(service) else context.startService(service)
}
}
}
@@ -0,0 +1,80 @@
package com.portaltv.capability
import android.content.Context
import android.os.Build
import android.provider.Settings
/** Resolves a human-readable Portal identity for mDNS / UI. */
object PortalDeviceIdentity {
data class Info(val name: String, val model: String) {
/** DNS-SD instance name shown in discovery UIs. */
val serviceName: String
get() {
val n = sanitize(name)
val m = sanitize(model)
return when {
n.isEmpty() && m.isEmpty() -> PortalEndpoints.MDNS_FALLBACK_NAME
n.isEmpty() -> m
m.isEmpty() || n.equals(m, ignoreCase = true) -> n
else -> "$n ($m)"
}
}
}
fun resolve(context: Context): Info {
val model = firstNonBlank(
Build.MODEL,
Build.PRODUCT,
"PortalTV",
)
val name = firstNonBlank(
settings(context, "bluetooth_name"),
settings(context, Settings.Global.DEVICE_NAME),
bluetoothAdapterName(),
model,
)
return Info(name = name, model = model)
}
private fun settings(context: Context, key: String): String? =
try {
Settings.Secure.getString(context.contentResolver, key)
?: Settings.Global.getString(context.contentResolver, key)
} catch (_: Exception) {
null
}
private fun bluetoothAdapterName(): String? =
try {
@Suppress("DEPRECATION")
android.bluetooth.BluetoothAdapter.getDefaultAdapter()?.name
} catch (_: Exception) {
null
}
private fun firstNonBlank(vararg values: String?): String =
values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty()
/** DNS-SD instance names: printable, ≤63 bytes, no dots (NsdManager quirk). */
fun sanitize(raw: String): String {
val cleaned = buildString(raw.length) {
for (ch in raw.trim()) {
when {
ch.isLetterOrDigit() || ch == ' ' || ch == '-' || ch == '_' || ch == '(' || ch == ')' ->
append(ch)
ch == '.' || ch == ',' || ch == ':' || ch == '/' ->
append(' ')
else -> Unit
}
}
}.replace(Regex("\\s+"), " ").trim()
if (cleaned.isEmpty()) return ""
val bytes = cleaned.toByteArray(Charsets.UTF_8)
if (bytes.size <= 63) return cleaned
var end = cleaned.length
while (end > 0 && cleaned.substring(0, end).toByteArray(Charsets.UTF_8).size > 63) {
end--
}
return cleaned.substring(0, end).trimEnd()
}
}
@@ -0,0 +1,13 @@
package com.portaltv.capability
/** Shared HTTPS / DNS-SD endpoints for PortalCam. */
object PortalEndpoints {
/** "TV" as ASCII little-endian nibble joke → decimal 5654. */
const val PORT = 5654
/** DNS-SD service type (trailing dot required by NsdManager). */
const val MDNS_TYPE = "_portalcam._tcp."
/** Fallback instance name when device identity is unavailable. */
const val MDNS_FALLBACK_NAME = "PortalCam"
}
@@ -0,0 +1,72 @@
package com.portaltv.capability
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/** Registers the Portal HTTPS endpoint on the LAN via DNS-SD / mDNS. */
class PortalMdns(context: Context) {
private val appContext = context.applicationContext
private val nsd = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager
@Volatile private var registered: NsdServiceInfo? = null
@Volatile private var registering = false
private val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) {
registered = info
registering = false
Log.i(TAG, "mDNS registered ${info.serviceName} ${info.serviceType}:${info.port}")
}
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
registering = false
Log.e(TAG, "mDNS registration failed code=$errorCode name=${info.serviceName}")
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
registered = null
Log.i(TAG, "mDNS unregistered ${info.serviceName}")
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS unregistration failed code=$errorCode")
}
}
fun register(port: Int = PortalEndpoints.PORT) {
if (registered != null || registering) return
registering = true
val identity = PortalDeviceIdentity.resolve(appContext)
val info = NsdServiceInfo().apply {
serviceName = identity.serviceName
serviceType = PortalEndpoints.MDNS_TYPE
setPort(port)
setAttribute("model", identity.model)
setAttribute("name", identity.name)
}
Log.i(TAG, "mDNS registering as \"${identity.serviceName}\" (name=${identity.name} model=${identity.model})")
try {
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (e: Exception) {
registering = false
Log.e(TAG, "mDNS registerService threw", e)
}
}
fun unregister() {
val info = registered
registered = null
registering = false
if (info == null) return
try {
nsd.unregisterService(listener)
} catch (e: Exception) {
Log.w(TAG, "mDNS unregister failed", e)
}
}
companion object {
private const val TAG = "PortalMdns"
}
}
@@ -0,0 +1,229 @@
package com.portaltv.capability
import android.content.Context
import com.portaltv.smartcamera.ControlSnapshot
import com.portaltv.smartcamera.CropConfig
import com.portaltv.smartcamera.DeskModeController
import com.portaltv.smartcamera.SmartCameraController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.util.concurrent.CopyOnWriteArrayList
/**
* Process-wide Smart Camera handle backed by [SmartCameraController].
* Shared by [PortalStreamingService] (HTTPS /control + SSE) and [MainActivity] (TV UI).
*
* State is event-driven from [SmartCameraController.control]; mutations ack only —
* observe [states] / [StateListener] for updates.
*/
object PortalSmartCamera {
private const val TIMEOUT_MS = 8_000L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Volatile private var camera: SmartCameraController? = null
private var collectJob: Job? = null
@Volatile private var latest: State = State("DefaultAuto", JSONObject())
private val _states = MutableSharedFlow<State>(replay = 1, extraBufferCapacity = 16)
/** Hot stream of camera state for SSE / coroutines. Replay=1 → new collectors get latest. */
val states: SharedFlow<State> = _states.asSharedFlow()
private val listeners = CopyOnWriteArrayList<StateListener>()
fun interface StateListener {
fun onState(state: State)
}
data class State(val mode: String, val config: JSONObject) {
fun toJson(): String = JSONObject()
.put("mode", mode)
.put("config", config)
.toString()
/** Content equality — [JSONObject] is identity-based by default. */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is State) return false
return mode == other.mode && config.toString() == other.config.toString()
}
override fun hashCode(): Int = 31 * mode.hashCode() + config.toString().hashCode()
}
sealed class Outcome {
data object Ack : Outcome() {
fun toJson(): String = JSONObject().put("ok", true).toString()
}
data class Err(val code: String, val message: String, val httpStatus: Int = 500) : Outcome() {
fun toJson(): String = JSONObject()
.put("error", code)
.put("message", message)
.toString()
}
}
@JvmStatic
fun start(context: Context) {
if (camera != null) return
synchronized(this) {
if (camera != null) return
val c = SmartCameraController(context.applicationContext, scope).also {
it.start(trackCrop = true)
}
camera = c
collectJob?.cancel()
collectJob = scope.launch {
c.control
.map { it.toPortalState() }
.distinctUntilChanged()
.collect { publish(it) }
}
}
}
@JvmStatic
fun currentState(): State = latest
/** Current state as JSON (one-shot; prefer SSE `/control/events`). */
@JvmStatic
fun stateJsonBlocking(): String = latest.toJson()
@JvmStatic
fun addStateListener(listener: StateListener) {
listeners.add(listener)
listener.onState(latest)
}
@JvmStatic
fun removeStateListener(listener: StateListener) {
listeners.remove(listener)
}
@JvmStatic
fun applyModeBlocking(mode: String): Outcome = mutateBlocking {
applyMode(mode, centerX = null, centerY = null, scale = null)
}
@JvmStatic
fun applyModeBlocking(
mode: String,
centerX: Float,
centerY: Float,
scale: Float,
): Outcome = mutateBlocking {
applyMode(mode, centerX, centerY, scale)
}
@JvmStatic
fun applyDeskTightnessBlocking(tightness: Float): Outcome = mutateBlocking {
applyDeskTightness(tightness)
}
/** Fire-and-forget for TV UI buttons. State arrives via [StateListener]. */
@JvmStatic
@JvmOverloads
fun setMode(mode: String, centerX: Float = 0.5f, centerY: Float = 0.5f, scale: Float = 1f) {
scope.launch {
runCatching {
if (mode.removePrefix("ModeSetting_") == "Fixed") {
applyMode(mode, centerX, centerY, scale)
} else {
applyMode(mode, null, null, null)
}
}.onFailure { android.util.Log.e("PortalSmartCamera", "setMode failed", it) }
}
}
@JvmStatic
fun setDeskTightness(tightness: Float) {
scope.launch {
runCatching { applyDeskTightness(tightness) }
.onFailure { android.util.Log.e("PortalSmartCamera", "setDeskTightness failed", it) }
}
}
private fun mutateBlocking(block: suspend () -> Unit): Outcome = runCatching {
runBlocking {
withTimeout(TIMEOUT_MS) { block() }
}
Outcome.Ack
}.getOrElse { e ->
Outcome.Err("set_mode_failed", e.message ?: e.toString())
}
private suspend fun applyMode(
mode: String,
centerX: Float?,
centerY: Float?,
scale: Float?,
) {
val c = camera ?: error("smart camera not started")
val short = mode.removePrefix("ModeSetting_")
val ok = when (short) {
"DefaultAuto" -> c.auto.activate()
"Desk" -> c.desk.activate(c.desk.tuning.value)
"Meeting" -> c.meeting.activate()
"Fixed" -> {
val crop = CropConfig(
centerX ?: c.fixed.crop.value.centerX,
centerY ?: c.fixed.crop.value.centerY,
scale ?: c.fixed.crop.value.scale,
).clamped()
c.fixed.setCrop(crop)
}
else -> error("mode must be DefaultAuto, Desk, Meeting, or Fixed")
}
if (!ok) error("setMode($short) was not accepted by Smart Camera")
}
private suspend fun applyDeskTightness(tightness: Float) {
val c = camera ?: error("smart camera not started")
val ok = c.desk.activate(DeskModeController.Tuning(framingTightness = tightness))
if (!ok) error("setMode(Desk) was not accepted by Smart Camera")
}
private fun publish(state: State) {
latest = state
_states.tryEmit(state)
for (l in listeners) {
runCatching { l.onState(state) }
.onFailure { android.util.Log.w("PortalSmartCamera", "listener failed", it) }
}
}
private fun ControlSnapshot.toPortalState(): State {
val short = shortMode?.takeIf { it in MODES } ?: latest.mode.takeIf { it in MODES } ?: "DefaultAuto"
val config = JSONObject()
when (short) {
"Fixed" -> {
config.putRounded("centerX", fixedCrop.centerX)
config.putRounded("centerY", fixedCrop.centerY)
config.putRounded("scale", fixedCrop.scale)
}
"Desk" -> {
deskTuning.framingTightness?.let { config.putRounded("framingTightness", it) }
deskTuning.trackingResponseDelayPct?.let { config.putRounded("trackingResponseDelayPct", it) }
deskTuning.trackingSensitivityPct?.let { config.putRounded("trackingSensitivityPct", it) }
deskTuning.transitionSpeedPct?.let { config.putRounded("transitionSpeedPct", it) }
}
}
return State(short, config)
}
private fun JSONObject.putRounded(key: String, value: Float) {
put(key, Math.round(value * 1000.0) / 1000.0)
}
private val MODES = setOf("DefaultAuto", "Desk", "Meeting", "Fixed")
}
@@ -0,0 +1,198 @@
package com.portaltv.capability
import android.util.Base64
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
object PortalSrp {
// RFC 5054 2048-bit prime
private const val N_HEX =
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" +
"020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" +
"4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" +
"98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" +
"9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" +
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" +
"3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF"
val N = BigInteger(N_HEX, 16)
val g = BigInteger.valueOf(2)
val k: BigInteger
init {
val nBytes = toPadded256(N)
val gBytes = toPadded256(g)
k = BigInteger(1, sha256(nBytes, gBytes))
}
private val random = SecureRandom()
data class ActivePairing(
val id: String,
val pin: String,
val salt: ByteArray,
val v: BigInteger,
val privB: BigInteger,
val pubB: BigInteger,
val expiresAt: Long,
var attemptsLeft: Int = 3
)
fun toPadded256(bi: BigInteger): ByteArray {
val raw = bi.toByteArray()
val result = ByteArray(256)
if (raw.size > 256) {
System.arraycopy(raw, raw.size - 256, result, 0, 256)
} else {
System.arraycopy(raw, 0, result, 256 - raw.size, raw.size)
}
return result
}
fun sha256(vararg parts: ByteArray): ByteArray {
val md = MessageDigest.getInstance("SHA-256")
for (p in parts) md.update(p)
return md.digest()
}
fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
if (a.size != b.size) return false
var result = 0
for (i in a.indices) {
result = result or (a[i].toInt() xor b[i].toInt())
}
return result == 0
}
fun isValidPublicA(A: BigInteger): Boolean = A.mod(N) != BigInteger.ZERO
fun isValidPublicB(B: BigInteger): Boolean = B.mod(N) != BigInteger.ZERO
fun isValidScrambler(u: BigInteger): Boolean = u != BigInteger.ZERO
fun newPairing(pin: String): ActivePairing {
val id = Base64.encodeToString(
ByteArray(12).also { random.nextBytes(it) },
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
)
val salt = ByteArray(16).also { random.nextBytes(it) }
// x = SHA256(salt || PIN)
val xBytes = sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// v = g^x mod N
val v = g.modPow(x, N)
// b = random 256-bit BigInteger, ensuring B mod N != 0
var b: BigInteger
var B: BigInteger
do {
val bBytes = ByteArray(32).also { random.nextBytes(it) }
b = BigInteger(1, bBytes).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE)
val gb = g.modPow(b, N)
B = k.multiply(v).add(gb).mod(N)
} while (!isValidPublicB(B))
return ActivePairing(
id = id,
pin = pin,
salt = salt,
v = v,
privB = b,
pubB = B,
expiresAt = System.currentTimeMillis() + 120_000L, // 2 minutes
attemptsLeft = 3
)
}
sealed class VerifyResult {
data class Success(val M2: ByteArray, val token: String) : VerifyResult()
data class Failed(val attemptsLeft: Int, val message: String) : VerifyResult()
}
fun verifyClient(
pairing: ActivePairing,
A_hex: String,
M1_hex: String,
tlsHash: ByteArray
): VerifyResult {
if (System.currentTimeMillis() > pairing.expiresAt) {
return VerifyResult.Failed(0, "Pairing session expired")
}
if (pairing.attemptsLeft <= 0) {
return VerifyResult.Failed(0, "Too many failed attempts; pairing cancelled")
}
val A = try {
BigInteger(A_hex, 16)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid client public key format")
}
// A mod N != 0
if (!isValidPublicA(A)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid public key A")
}
val M1 = try {
hexToBytes(M1_hex)
} catch (_: Exception) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Invalid M1 format")
}
val A_bytes = toPadded256(A)
val B_bytes = toPadded256(pairing.pubB)
// u = SHA256(PAD(A) || PAD(B))
val uBytes = sha256(A_bytes, B_bytes)
val u = BigInteger(1, uBytes)
if (!isValidScrambler(u)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Scrambler u is zero")
}
// Server computes S = (A * v^u mod N)^b mod N
val vu = pairing.v.modPow(u, N)
val S = A.multiply(vu).mod(N).modPow(pairing.privB, N)
val S_bytes = toPadded256(S)
// K = SHA256(PAD(S))
val K = sha256(S_bytes)
// Expected M1 = SHA256(PAD(A) || PAD(B) || K || salt || tlsHash)
val expectedM1 = sha256(A_bytes, B_bytes, K, pairing.salt, tlsHash)
if (!constantTimeEquals(M1, expectedM1)) {
pairing.attemptsLeft--
return VerifyResult.Failed(pairing.attemptsLeft, "Authentication failed (wrong PIN or MITM detected)")
}
// M2 = SHA256(PAD(A) || M1 || K || tlsHash)
val M2 = sha256(A_bytes, M1, K, tlsHash)
// Generate cryptographically secure bearer token
val tokenBytes = ByteArray(32).also { random.nextBytes(it) }
val token = Base64.encodeToString(tokenBytes, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)
return VerifyResult.Success(M2 = M2, token = token)
}
fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
val len = clean.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
i += 2
}
return data
}
}
@@ -0,0 +1,105 @@
package com.portaltv.capability
import java.math.BigInteger
import java.security.SecureRandom
/**
* SRP-6a client implementation matching RFC 5054 2048-bit MODP group
* with cryptographic TLS channel binding.
*
* Compatible with PortalCam client (PortalSrpClient.swift) and PortalSrp server.
*/
class PortalSrpClient(
customA: BigInteger? = null,
private val random: SecureRandom = SecureRandom()
) {
@get:JvmName("getPrivateA")
val a: BigInteger
@get:JvmName("getPublicA")
val A: BigInteger
var K: ByteArray? = null
private set
var M1: ByteArray? = null
private set
private var tlsCertHash: ByteArray? = null
init {
if (customA != null) {
a = customA
} else {
val aBytes = ByteArray(32).also { random.nextBytes(it) }
a = BigInteger(1, aBytes).mod(PortalSrp.N.subtract(BigInteger.valueOf(2))).add(BigInteger.ONE)
}
A = PortalSrp.g.modPow(a, PortalSrp.N)
}
val pubAHex: String
get() = PortalSrp.bytesToHex(PortalSrp.toPadded256(A))
/**
* Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
* Enforces safety checks: B mod N != 0 and u != 0.
*/
fun computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: ByteArray): String {
val salt = PortalSrp.hexToBytes(saltHex)
require(salt.isNotEmpty()) { "Invalid salt hex" }
val bBytes = PortalSrp.hexToBytes(pubBHex)
val B = BigInteger(1, bBytes)
// Safety check B % N != 0
require(PortalSrp.isValidPublicB(B)) { "Server public value B % N == 0" }
// u = SHA256(pad256(A) || pad256(B))
val uBytes = PortalSrp.sha256(PortalSrp.toPadded256(A), PortalSrp.toPadded256(B))
val u = BigInteger(1, uBytes)
require(PortalSrp.isValidScrambler(u)) { "Computed u == 0" }
// x = SHA256(salt || UTF8(pin))
val xBytes = PortalSrp.sha256(salt, pin.toByteArray(Charsets.UTF_8))
val x = BigInteger(1, xBytes)
// S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
val gx = PortalSrp.g.modPow(x, PortalSrp.N)
val kgx = PortalSrp.k.multiply(gx).mod(PortalSrp.N)
val base = B.subtract(kgx).mod(PortalSrp.N)
val exp = a.add(u.multiply(x))
val S = base.modPow(exp, PortalSrp.N)
// K = SHA256(pad256(S))
val sessionK = PortalSrp.sha256(PortalSrp.toPadded256(S))
this.K = sessionK
this.tlsCertHash = tlsCertSha256
// M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256)
val clientM1 = PortalSrp.sha256(
PortalSrp.toPadded256(A),
PortalSrp.toPadded256(B),
sessionK,
salt,
tlsCertSha256
)
this.M1 = clientM1
return PortalSrp.bytesToHex(clientM1)
}
/**
* Verify server's M2 response.
* Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256).
*/
fun verifyServerM2(serverM2Hex: String): Boolean {
val expectedM1 = M1 ?: throw IllegalStateException("Client state not initialized for verification")
val sessionK = K ?: throw IllegalStateException("Client state not initialized for verification")
val certHash = tlsCertHash ?: throw IllegalStateException("Client state not initialized for verification")
val serverM2 = try {
PortalSrp.hexToBytes(serverM2Hex)
} catch (e: Exception) {
return false
}
val expectedM2 = PortalSrp.sha256(PortalSrp.toPadded256(A), expectedM1, sessionK, certHash)
return PortalSrp.constantTimeEquals(expectedM2, serverM2)
}
}
@@ -0,0 +1,746 @@
package com.portaltv.capability
import android.app.*
import android.content.*
import android.graphics.SurfaceTexture
import android.hardware.camera2.*
import android.media.*
import android.os.*
import android.view.Surface
import java.net.*
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
import javax.net.ssl.SSLServerSocket
/** Foreground, UI-independent Portal raw media service over HTTPS. */
class PortalStreamingService : Service() {
companion object { @JvmField @Volatile var activityVisible = false }
private val video = Track(true); private val audio = Track(false)
private val videoUsers = AtomicInteger(); private val audioUsers = AtomicInteger()
private var camera: CameraDevice? = null; private var cameraSession: CameraCaptureSession? = null
private var reader: MediaCodec? = null; private var audioCodec: MediaCodec? = null
private var videoSurface: Surface? = null; private var mic: AudioRecord? = null
private var server: SSLServerSocket? = null; private val io = Executors.newCachedThreadPool()
private val cameraHandler = Handler(Looper.getMainLooper())
private val videoLock = Any()
private val audioLock = Any()
@Volatile private var audioLoopActive = false
private var audioThread: Thread? = null
private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) }
private val random = SecureRandom()
@Volatile private var pairing: PortalSrp.ActivePairing? = null
@Volatile private var recoveringCamera = false
/** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */
@Volatile private var cameraGeneration = 0
private var mdns: PortalMdns? = null
override fun onCreate() {
super.onCreate()
android.util.Log.d("PortalService", "onCreate - initializing TLS")
startForeground(42, notification())
PortalSmartCamera.start(this)
try {
server = PortalTls.createServerSocket(this, PortalEndpoints.PORT)
android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}")
mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) }
} catch (e: Exception) {
android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e)
}
cameraHandler.post(object : Runnable {
override fun run() {
authPrefs.edit()
.putInt("activeVideo", videoUsers.get())
.putInt("activeAudio", audioUsers.get())
.apply()
cameraHandler.postDelayed(this, 1000)
}
})
Thread { acceptLoop() }.start()
}
override fun onStartCommand(i: Intent?, flags: Int, id: Int): Int {
if (i?.action == "com.portaltv.capability.CLOSE_CLIENTS") {
video.clear(); audio.clear(); return START_STICKY
}
if (i?.action == "com.portaltv.capability.REVOKE_ALL") {
authPrefs.edit().clear().apply(); video.clear(); audio.clear()
android.util.Log.i("PortalService", "all clients revoked")
return START_STICKY
}
if (videoUsers.get() > 0 && reader == null) startVideo()
return START_STICKY
}
override fun onBind(i: Intent?): IBinder? = null
private fun notification(): Notification {
val ch = NotificationChannel("portal", "Portal camera", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
return Notification.Builder(this, "portal")
.setContentTitle("Portal camera streaming (HTTPS)")
.setSmallIcon(android.R.drawable.presence_video_online)
.build()
}
private fun acceptLoop() {
val listener = server ?: return
while (!listener.isClosed) {
runCatching {
val client = listener.accept()
io.submit { handle(client) }
}.onFailure {
if (!listener.isClosed) {
android.util.Log.w("PortalService", "accept failed", it)
}
}
}
}
private fun hash(s: String) =
MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private fun startSrpPairing(): PortalSrp.ActivePairing {
val existing = pairing
if (existing != null && System.currentTimeMillis() < existing.expiresAt && existing.attemptsLeft > 0) {
android.util.Log.i("PortalService", "SRP pairing started with PIN: ${existing.pin}")
return existing
}
val pin = (100000 + random.nextInt(900000)).toString()
val active = PortalSrp.newPairing(pin)
pairing = active
authPrefs.edit().putString("pairingPin", pin).apply()
cameraHandler.post {
android.widget.Toast.makeText(this, "Pairing PIN: $pin", android.widget.Toast.LENGTH_LONG).show()
}
bringActivityToFront()
android.util.Log.i("PortalService", "SRP pairing started with PIN: $pin")
return active
}
private fun authToken(headers: Map<String, String>): String? {
val a = headers["authorization"] ?: return null
if (!a.startsWith("Bearer ")) return null
val token = a.substring(7)
val h = hash(token)
return if ((authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).contains(h)) token else null
}
private fun handle(s: Socket) {
s.use { socket ->
socket.soTimeout = 5000
val reader = socket.getInputStream().bufferedReader()
val line = reader.readLine() ?: return
val headers = mutableMapOf<String, String>()
while (true) {
val h = reader.readLine() ?: break
if (h.isEmpty()) break
val k = h.indexOf(':')
if (k > 0) headers[h.substring(0, k).lowercase()] = h.substring(k + 1).trim()
}
val parts = line.split(" ")
val method = parts.getOrNull(0) ?: "GET"
val path = parts.getOrNull(1) ?: return
// If request has Content-Length, read body
var body = ""
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
if (contentLength in 1..65536) {
val buf = CharArray(contentLength)
var readTotal = 0
while (readTotal < contentLength) {
val r = reader.read(buf, readTotal, contentLength - readTotal)
if (r < 0) break
readTotal += r
}
body = String(buf, 0, readTotal)
}
when {
// SRP-6a pairing endpoints
path.startsWith("/auth/srp/init") -> handleSrpInit(socket)
path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers)
// TLS Info endpoint (returns server cert SHA-256 for diagnostics)
path.startsWith("/auth/cert") -> {
val sha = PortalSrp.bytesToHex(PortalTls.certSha256)
reply(socket, 200, "{\"certSha256\":\"$sha\"}", "application/json")
}
// Media streams (require Bearer auth)
path.startsWith("/video.h264") -> {
if (authToken(headers) != null) stream(socket, video, videoUsers, "video/h264", true)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/audio.aac") -> {
if (authToken(headers) != null) stream(socket, audio, audioUsers, "audio/aac", false)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
path.startsWith("/control") -> {
if (authToken(headers) != null) control(socket, path)
else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json")
}
else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json")
}
}
}
private fun handleSrpInit(s: Socket) {
val p = startSrpPairing()
val saltHex = PortalSrp.bytesToHex(p.salt)
val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB))
val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":120}"
reply(s, 200, json, "application/json")
}
private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map<String, String>) {
val p = pairing
if (p == null) {
reply(s, 400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}", "application/json")
return
}
// Parse params from query string or JSON body
val queryParams = path.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun extractParam(key: String): String? {
queryParams[key]?.let { return it }
// Basic JSON search: "key":"value" or "key": "value"
val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"")
return pattern.find(body)?.groupValues?.getOrNull(1)
}
val pairingId = extractParam("pairingId")
val A = extractParam("A")
val M1 = extractParam("M1")
if (pairingId == null || A == null || M1 == null) {
reply(s, 400, "{\"error\":\"missing_parameters\",\"message\":\"pairingId, A, and M1 are required\"}", "application/json")
return
}
if (pairingId != p.id) {
reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json")
return
}
val tlsHash = PortalTls.certSha256
val res = PortalSrp.verifyClient(p, A, M1, tlsHash)
when (res) {
is PortalSrp.VerifyResult.Success -> {
val token = res.token
val h = hash(token)
val set = (authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).toMutableSet()
set += h
val clientMeta = (headers["user-agent"] ?: "unknown") + "|" +
(headers["x-client-name"] ?: "") + "|" +
(headers["x-client-version"] ?: "") + "|" +
System.currentTimeMillis()
authPrefs.edit()
.putStringSet("tokens", set)
.putString("client.$h", clientMeta)
.remove("pairingPin")
.apply()
pairing = null
val m2Hex = PortalSrp.bytesToHex(res.M2)
val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}"
android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta")
reply(s, 200, json, "application/json")
}
is PortalSrp.VerifyResult.Failed -> {
android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}")
if (res.attemptsLeft <= 0) {
pairing = null
authPrefs.edit().remove("pairingPin").apply()
}
reply(
s,
401,
"{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}",
"application/json"
)
}
}
}
private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean) {
s.soTimeout = 0 // Don't timeout streaming connections
val o = s.getOutputStream()
o.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray())
o.flush()
val q = t.add(key)
val active = n.incrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active")
if (active == 1) {
if (t === video) startVideo() else startAudio()
if (key) bringActivityToFront()
}
try {
var wait = key
while (!s.isClosed && t.contains(q)) {
val p = q.poll(1, TimeUnit.SECONDS) ?: continue
if (!wait || p.key) {
wait = false
o.write(p.data)
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}")
} finally {
t.remove(q)
val left = n.decrementAndGet()
android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left")
if (left == 0) {
if (t === video) {
android.util.Log.i("PortalService", "last video client gone; stopping camera")
stopVideo()
} else {
android.util.Log.i("PortalService", "last audio client gone; stopping microphone")
stopAudio()
}
}
}
}
private fun bringActivityToFront() {
if (activityVisible) return
runCatching {
startActivity(
Intent().setClassName(this, "com.portaltv.capability.MainActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
)
}.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) }
}
private fun control(s: Socket, p: String) {
val pathOnly = p.substringBefore('?')
val q = p.substringAfter('?', "")
.split('&')
.mapNotNull {
val kv = it.split('=', limit = 2)
if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null
}.toMap()
fun replyOutcome(outcome: PortalSmartCamera.Outcome) {
when (outcome) {
is PortalSmartCamera.Outcome.Ack ->
reply(s, 200, outcome.toJson(), "application/json")
is PortalSmartCamera.Outcome.Err ->
reply(s, outcome.httpStatus, outcome.toJson(), "application/json")
}
}
when {
pathOnly == "/control/events" -> {
controlEvents(s)
}
pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> {
reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json")
}
pathOnly.startsWith("/control/mode") -> {
val mode = q["mode"]
if (mode == null || mode !in setOf("DefaultAuto", "Desk", "Meeting", "Fixed")) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_mode",
"mode must be DefaultAuto, Desk, Meeting, or Fixed",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking(mode))
}
pathOnly.startsWith("/control/fixed") -> {
val x = q["x"]?.toFloatOrNull()
val y = q["y"]?.toFloatOrNull()
val scale = q["scale"]?.toFloatOrNull()
if (x == null || y == null || scale == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_fixed",
"x, y, and scale are required numbers",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyModeBlocking("Fixed", x, y, scale))
}
pathOnly.startsWith("/control/desk") -> {
val tightness = q["tightness"]?.toFloatOrNull()
if (tightness == null) {
reply(
s, 400,
PortalSmartCamera.Outcome.Err(
"invalid_desk",
"tightness is a required number",
400,
).toJson(),
"application/json",
)
return
}
replyOutcome(PortalSmartCamera.applyDeskTightnessBlocking(tightness))
}
else -> reply(
s, 404,
PortalSmartCamera.Outcome.Err("not_found", "unknown control path", 404).toJson(),
"application/json",
)
}
}
/** SSE: initial state, then `event: state` on each change. */
private fun controlEvents(s: Socket) {
s.soTimeout = 0
val o = s.getOutputStream()
o.write(
("HTTP/1.1 200 OK\r\n" +
"Content-Type: text/event-stream\r\n" +
"Cache-Control: no-store\r\n" +
"Connection: keep-alive\r\n\r\n").toByteArray(Charsets.UTF_8)
)
o.flush()
fun writeEvent(state: PortalSmartCamera.State) {
val payload = "event: state\ndata: ${state.toJson()}\n\n"
o.write(payload.toByteArray(Charsets.UTF_8))
o.flush()
}
val queue = LinkedBlockingQueue<PortalSmartCamera.State>(32)
val listener = PortalSmartCamera.StateListener { state ->
// Drop oldest if slow client; keep connection alive.
while (!queue.offer(state)) {
queue.poll()
}
}
PortalSmartCamera.addStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client connected")
try {
// addStateListener already pushed latest; also write a comment keepalive loop.
while (!s.isClosed) {
val next = queue.poll(15, TimeUnit.SECONDS)
if (next != null) {
writeEvent(next)
} else {
o.write(": keepalive\n\n".toByteArray(Charsets.UTF_8))
o.flush()
}
}
} catch (e: Exception) {
android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}")
} finally {
PortalSmartCamera.removeStateListener(listener)
android.util.Log.i("PortalService", "SSE /control/events client removed")
runCatching { s.close() }
}
}
private fun reply(s: Socket, c: Int, b: String, contentType: String = "text/plain") {
val reason = when (c) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
404 -> "Not Found"
else -> "Error"
}
val d = b.toByteArray(Charsets.UTF_8)
s.getOutputStream().write(
("HTTP/1.1 $c $reason\r\nContent-Type: $contentType\r\nContent-Length: ${d.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8) + d
)
}
private fun startVideo() {
synchronized(videoLock) {
if (reader != null && videoSurface != null) {
android.util.Log.d("PortalService", "startVideo skipped; encoder already running")
return
}
android.util.Log.d("PortalService", "startVideo")
try {
// Tear down any half-open previous pipeline before starting a new one.
stopVideoLocked()
val generation = ++cameraGeneration
val f = MediaFormat.createVideoFormat("video/avc", 1280, 720)
f.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
f.setInteger(MediaFormat.KEY_BIT_RATE, 2500000)
f.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
reader = MediaCodec.createEncoderByType("video/avc")
reader!!.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
videoSurface = reader!!.createInputSurface()
reader!!.start()
openCamera(generation, videoSurface!!)
drainVideo()
} catch (e: Exception) {
android.util.Log.e("PortalService", "startVideo failed", e)
stopVideoLocked()
}
}
}
private fun drainVideo() {
Thread {
val b = MediaCodec.BufferInfo()
var count = 0
while (reader != null) try {
val i = reader!!.dequeueOutputBuffer(b, 10000)
if (i >= 0) {
val x = reader!!.getOutputBuffer(i)
if (x != null && b.size > 0) {
val d = ByteArray(b.size)
x.position(b.offset)
x.get(d)
val key = (b.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0 || (b.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0
video.publish(d, key, b.presentationTimeUs)
if (++count % 30 == 0) android.util.Log.d("PortalService", "video packets=$count bytes=${d.size} key=$key")
}
reader!!.releaseOutputBuffer(i, false)
}
} catch (e: Exception) {
if (reader != null) android.util.Log.e("PortalService", "video drain stopped", e)
break
}
}.start()
}
private fun openCamera(generation: Int, surface: Surface) {
val cm = getSystemService(CameraManager::class.java)
val id = cm.cameraIdList.firstOrNull()
if (id == null) {
android.util.Log.e("PortalService", "no cameras available")
return
}
if (checkSelfPermission("android.permission.CAMERA") != 0) {
android.util.Log.e("PortalService", "camera permission denied")
return
}
android.util.Log.d("PortalService", "opening camera $id (gen=$generation)")
try {
cm.openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(c: CameraDevice) {
if (generation != cameraGeneration || surface !== videoSurface) {
android.util.Log.w("PortalService", "stale camera onOpened (gen=$generation); closing")
runCatching { c.close() }
return
}
android.util.Log.d("PortalService", "camera opened (gen=$generation)")
try {
camera = c
val q = c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD)
q.addTarget(surface)
c.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
if (generation != cameraGeneration) {
android.util.Log.w("PortalService", "stale capture onConfigured; closing session")
runCatching { s.close() }
return
}
android.util.Log.d("PortalService", "capture configured")
try {
cameraSession = s
s.setRepeatingRequest(q.build(), null, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "setRepeatingRequest failed", e)
recoverCamera()
}
}
override fun onConfigureFailed(s: CameraCaptureSession) {
android.util.Log.e("PortalService", "capture configure failed")
recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "camera onOpened setup failed", e)
runCatching { c.close() }
recoverCamera()
}
}
override fun onDisconnected(c: CameraDevice) {
android.util.Log.e("PortalService", "camera disconnected")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
override fun onError(c: CameraDevice, e: Int) {
android.util.Log.e("PortalService", "camera error $e")
runCatching { c.close() }
if (generation == cameraGeneration) recoverCamera()
}
}, cameraHandler)
} catch (e: Exception) {
android.util.Log.e("PortalService", "openCamera failed", e)
recoverCamera()
}
}
private fun recoverCamera() {
if (videoUsers.get() <= 0 || recoveringCamera) return
recoveringCamera = true
cameraHandler.postDelayed({
recoveringCamera = false
if (videoUsers.get() > 0) {
stopVideo()
startVideo()
}
}, 750)
}
private fun stopVideo() {
synchronized(videoLock) {
stopVideoLocked()
}
}
private fun stopVideoLocked() {
cameraGeneration++
runCatching { cameraSession?.close() }
runCatching { camera?.close() }
cameraSession = null
camera = null
runCatching { videoSurface?.release() }
videoSurface = null
runCatching {
reader?.stop()
reader?.release()
}
reader = null
}
private fun startAudio() {
synchronized(audioLock) {
stopAudioLocked()
try {
val f = MediaFormat.createAudioFormat("audio/mp4a-latm", 48000, 1)
f.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
f.setInteger(MediaFormat.KEY_BIT_RATE, 64000)
val codec = MediaCodec.createEncoderByType("audio/mp4a-latm")
codec.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
audioCodec = codec
val n = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val record = AudioRecord(
MediaRecorder.AudioSource.DEFAULT,
48000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
n * 2
)
record.startRecording()
mic = record
audioLoopActive = true
audioThread = Thread {
val pcm = ByteArray(n)
val info = MediaCodec.BufferInfo()
while (audioLoopActive) {
try {
val got = record.read(pcm, 0, pcm.size)
if (!audioLoopActive) break
if (got > 0) {
val i = codec.dequeueInputBuffer(10_000)
if (!audioLoopActive) break
if (i >= 0) {
val x = codec.getInputBuffer(i) ?: continue
x.clear()
x.put(pcm, 0, got)
codec.queueInputBuffer(i, 0, got, System.nanoTime() / 1000, 0)
}
}
val o = codec.dequeueOutputBuffer(info, 0)
if (!audioLoopActive) break
if (o >= 0) {
val x = codec.getOutputBuffer(o)
if (x != null && info.size > 0) {
val d = ByteArray(info.size)
x.position(info.offset)
x.get(d)
audio.publish(d, false, info.presentationTimeUs)
}
codec.releaseOutputBuffer(o, false)
}
} catch (e: Exception) {
if (audioLoopActive) {
android.util.Log.w("PortalService", "audio loop stopped", e)
}
break
}
}
}.also {
it.name = "portal-audio"
it.start()
}
} catch (e: Exception) {
android.util.Log.e("PortalService", "startAudio failed", e)
stopAudioLocked()
}
}
}
private fun stopAudio() {
synchronized(audioLock) {
stopAudioLocked()
}
}
private fun stopAudioLocked() {
audioLoopActive = false
runCatching { mic?.stop() } // unblock AudioRecord.read
val t = audioThread
audioThread = null
if (t != null && t !== Thread.currentThread()) {
runCatching { t.join(750) }
}
runCatching { mic?.release() }
mic = null
runCatching {
audioCodec?.stop()
audioCodec?.release()
}
audioCodec = null
}
override fun onDestroy() {
mdns?.unregister()
mdns = null
server?.close()
stopVideo()
stopAudio()
io.shutdownNow()
super.onDestroy()
}
class Track(val v: Boolean) {
data class P(val data: ByteArray, val key: Boolean, val pts: Long)
private val qs = CopyOnWriteArraySet<LinkedBlockingDeque<P>>()
@Volatile private var config: ByteArray? = null
fun add(k: Boolean) = LinkedBlockingDeque<P>(if (v) 3 else 256).also {
qs += it
config?.let { c -> if (v) it.offer(P(c, true, 0)) }
}
fun clear() { qs.clear() }
fun contains(q: LinkedBlockingDeque<P>) = qs.contains(q)
fun remove(q: LinkedBlockingDeque<P>) { qs -= q }
fun publish(d: ByteArray, k: Boolean, p: Long) {
if (v && k && d.size < 256) config = d
qs.forEach { if (!it.offer(P(d, k, p))) qs -= it }
}
}
}
@@ -0,0 +1,108 @@
package com.portaltv.capability
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.math.BigInteger
import java.net.Socket
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.Principal
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.security.spec.ECGenParameterSpec
import java.util.Date
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLServerSocket
import javax.net.ssl.X509ExtendedKeyManager
import javax.security.auth.x500.X500Principal
object PortalTls {
private const val ALIAS = "portalcam_tls_ec_p256"
private const val KS_TYPE = "AndroidKeyStore"
@Volatile
var certDer: ByteArray = ByteArray(0)
private set
@Volatile
var certSha256: ByteArray = ByteArray(0)
private set
fun getOrCreateSslContext(context: Context): SSLContext {
val ks = KeyStore.getInstance(KS_TYPE).apply { load(null) }
if (!ks.containsAlias(ALIAS)) {
android.util.Log.i("PortalTls", "Generating new EC secp256r1 self-signed certificate in AndroidKeyStore")
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KS_TYPE)
val now = System.currentTimeMillis()
val notBefore = Date(now - 86400000L) // 1 day ago
val notAfter = Date(now + 10L * 365 * 86400000L) // 10 years
val spec = KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_SIGN
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setCertificateSubject(X500Principal("CN=PortalCam, O=PortalCam, C=US"))
.setCertificateSerialNumber(BigInteger.valueOf(now))
.setCertificateNotBefore(notBefore)
.setCertificateNotAfter(notAfter)
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512
)
.build()
kpg.initialize(spec)
kpg.generateKeyPair()
}
val cert = ks.getCertificate(ALIAS) as X509Certificate
certDer = cert.encoded
certSha256 = MessageDigest.getInstance("SHA-256").digest(certDer)
android.util.Log.i("PortalTls", "Certificate SHA-256: ${certSha256.joinToString("") { "%02x".format(it) }}")
// Build KeyManager that retrieves key from AndroidKeyStore
val km = object : X509ExtendedKeyManager() {
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> = arrayOf(ALIAS)
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String = ALIAS
override fun chooseEngineServerAlias(keyType: String?, issuers: Array<out Principal>?, engine: SSLEngine?): String = ALIAS
override fun getCertificateChain(alias: String?): Array<X509Certificate>? = arrayOf(cert)
override fun getPrivateKey(alias: String?): PrivateKey? = ks.getKey(ALIAS, null) as? PrivateKey
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), null, SecureRandom())
return sslContext
}
fun createServerSocket(context: Context, port: Int): SSLServerSocket {
val sslCtx = getOrCreateSslContext(context)
val s = sslCtx.serverSocketFactory.createServerSocket(port) as SSLServerSocket
s.needClientAuth = false
s.wantClientAuth = false
val supported = s.supportedProtocols.toList()
android.util.Log.i("PortalTls", "Supported protocols: $supported")
val desired = listOf("TLSv1.3", "TLSv1.2").filter { it in supported }
if (desired.isNotEmpty()) {
s.enabledProtocols = desired.toTypedArray()
}
android.util.Log.i("PortalTls", "Enabled protocols: ${s.enabledProtocols.contentToString()}")
return s
}
}
@@ -0,0 +1,14 @@
/* AUTO-GENERATED FILE. DO NOT MODIFY.
*
* This class was automatically generated by the
* aapt tool from the resource data it found. It
* should not be modified by hand.
*/
package com.portaltv.capability;
public final class R {
public static final class style {
public static final int AppTheme=0x7f010000;
}
}