Init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user